Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor video uploads (#5570)

* Remove unused video field

* Stop exposing video dispatch

* Move cancellation out of the reducer

* Make useUploadStatusQuery controlled by jobId

* Rename SetStatus to SetProcessing

This action only has one callsite and it's always passing "processing".

* Move jobId into video reducer state

* Make cancellation scoped

* Inline useCompressVideoMutation

* Move processVideo down the file

* Extract getErrorMessage

* useServiceAuthToken -> getServiceAuthToken

* useVideoAgent -> createVideoAgent

* useVideoUploadLimits -> getVideoUploadLimits

* useUploadVideoMutation -> uploadVideo

* Use async/await in processVideo

* Inline onVideoCompressed into processVideo

* Use async/await for uploadVideo

* Factor out error messages

* Guard dispatch with signal

This lets us remove the scattered signal checks around dispatch.

* Move job polling out of RQ

* Handle poll failures

* Remove unnecessary guards

* Slightly more accurate condition

* Move initVideoUri handling out of the hook

* Remove dead argument

It wasn't being used before either.

* Remove unused detailed status

This isn't being used because we're only respecting that state variable when isProcessing=true, but isProcessing is always false during video upload.

If we want to re-add this later, it should really just be derived from the reducer state.

* Harden the video reducer

* Tie all spawned work to a signal

* Preserve asset/media for nicer error state

* Rename actions to match states

* Inline useUploadVideo

This abstraction is getting in the way of some future work.

* Move MIME check to the only place that handles it

authored by

dan and committed by
GitHub
d2392d2d c2dac855

+644 -559
-39
src/state/queries/video/compress-video.ts
··· 1 - import {ImagePickerAsset} from 'expo-image-picker' 2 - import {useMutation} from '@tanstack/react-query' 3 - 4 - import {cancelable} from '#/lib/async/cancelable' 5 - import {CompressedVideo} from '#/lib/media/video/types' 6 - import {compressVideo} from 'lib/media/video/compress' 7 - 8 - export function useCompressVideoMutation({ 9 - onProgress, 10 - onSuccess, 11 - onError, 12 - signal, 13 - }: { 14 - onProgress: (progress: number) => void 15 - onError: (e: any) => void 16 - onSuccess: (video: CompressedVideo) => void 17 - signal: AbortSignal 18 - }) { 19 - return useMutation({ 20 - mutationKey: ['video', 'compress'], 21 - mutationFn: cancelable( 22 - (asset: ImagePickerAsset) => 23 - compressVideo(asset, { 24 - onProgress: num => onProgress(trunc2dp(num)), 25 - signal, 26 - }), 27 - signal, 28 - ), 29 - onError, 30 - onSuccess, 31 - onMutate: () => { 32 - onProgress(0) 33 - }, 34 - }) 35 - } 36 - 37 - function trunc2dp(num: number) { 38 - return Math.trunc(num * 100) / 100 39 - }
+4 -7
src/state/queries/video/util.ts
··· 1 - import {useMemo} from 'react' 2 1 import {AtpAgent} from '@atproto/api' 3 2 4 3 import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' ··· 17 16 return url.href 18 17 } 19 18 20 - export function useVideoAgent() { 21 - return useMemo(() => { 22 - return new AtpAgent({ 23 - service: VIDEO_SERVICE, 24 - }) 25 - }, []) 19 + export function createVideoAgent() { 20 + return new AtpAgent({ 21 + service: VIDEO_SERVICE, 22 + }) 26 23 } 27 24 28 25 export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+38 -50
src/state/queries/video/video-upload.shared.ts
··· 1 - import {useCallback} from 'react' 1 + import {BskyAgent} from '@atproto/api' 2 + import {I18n} from '@lingui/core' 2 3 import {msg} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 4 4 5 5 import {VIDEO_SERVICE_DID} from '#/lib/constants' 6 6 import {UploadLimitError} from '#/lib/media/video/errors' 7 7 import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' 8 - import {useAgent} from '#/state/session' 9 - import {useVideoAgent} from './util' 8 + import {createVideoAgent} from './util' 10 9 11 - export function useServiceAuthToken({ 10 + export async function getServiceAuthToken({ 11 + agent, 12 12 aud, 13 13 lxm, 14 14 exp, 15 15 }: { 16 + agent: BskyAgent 16 17 aud?: string 17 18 lxm: string 18 19 exp?: number 19 20 }) { 20 - const agent = useAgent() 21 - 22 - return useCallback(async () => { 23 - const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) 24 - 25 - if (!pdsAud) { 26 - throw new Error('Agent does not have a PDS URL') 27 - } 28 - 29 - const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ 30 - aud: aud ?? pdsAud, 31 - lxm, 32 - exp, 33 - }) 34 - 35 - return serviceAuth.token 36 - }, [agent, aud, lxm, exp]) 21 + const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) 22 + if (!pdsAud) { 23 + throw new Error('Agent does not have a PDS URL') 24 + } 25 + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ 26 + aud: aud ?? pdsAud, 27 + lxm, 28 + exp, 29 + }) 30 + return serviceAuth.token 37 31 } 38 32 39 - export function useVideoUploadLimits() { 40 - const agent = useVideoAgent() 41 - const getToken = useServiceAuthToken({ 33 + export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { 34 + const token = await getServiceAuthToken({ 35 + agent, 42 36 lxm: 'app.bsky.video.getUploadLimits', 43 37 aud: VIDEO_SERVICE_DID, 44 38 }) 45 - const {_} = useLingui() 46 - 47 - return useCallback(async () => { 48 - const {data: limits} = await agent.app.bsky.video 49 - .getUploadLimits( 50 - {}, 51 - {headers: {Authorization: `Bearer ${await getToken()}`}}, 52 - ) 53 - .catch(err => { 54 - if (err instanceof Error) { 55 - throw new UploadLimitError(err.message) 56 - } else { 57 - throw err 58 - } 59 - }) 60 - 61 - if (!limits.canUpload) { 62 - if (limits.message) { 63 - throw new UploadLimitError(limits.message) 39 + const videoAgent = createVideoAgent() 40 + const {data: limits} = await videoAgent.app.bsky.video 41 + .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) 42 + .catch(err => { 43 + if (err instanceof Error) { 44 + throw new UploadLimitError(err.message) 64 45 } else { 65 - throw new UploadLimitError( 66 - _( 67 - msg`You have temporarily reached the limit for video uploads. Please try again later.`, 68 - ), 69 - ) 46 + throw err 70 47 } 48 + }) 49 + 50 + if (!limits.canUpload) { 51 + if (limits.message) { 52 + throw new UploadLimitError(limits.message) 53 + } else { 54 + throw new UploadLimitError( 55 + _( 56 + msg`You have temporarily reached the limit for video uploads. Please try again later.`, 57 + ), 58 + ) 71 59 } 72 - }, [agent, _, getToken]) 60 + } 73 61 }
+57 -54
src/state/queries/video/video-upload.ts
··· 1 1 import {createUploadTask, FileSystemUploadType} from 'expo-file-system' 2 - import {AppBskyVideoDefs} from '@atproto/api' 2 + import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' 3 + import {I18n} from '@lingui/core' 3 4 import {msg} from '@lingui/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {useMutation} from '@tanstack/react-query' 6 5 import {nanoid} from 'nanoid/non-secure' 7 6 8 - import {cancelable} from '#/lib/async/cancelable' 7 + import {AbortError} from '#/lib/async/cancelable' 9 8 import {ServerError} from '#/lib/media/video/errors' 10 9 import {CompressedVideo} from '#/lib/media/video/types' 11 10 import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' 12 - import {useSession} from '#/state/session' 13 - import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' 11 + import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' 14 12 15 - export const useUploadVideoMutation = ({ 16 - onSuccess, 17 - onError, 13 + export async function uploadVideo({ 14 + video, 15 + agent, 16 + did, 18 17 setProgress, 19 18 signal, 19 + _, 20 20 }: { 21 - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 22 - onError: (e: any) => void 21 + video: CompressedVideo 22 + agent: BskyAgent 23 + did: string 23 24 setProgress: (progress: number) => void 24 25 signal: AbortSignal 25 - }) => { 26 - const {currentAccount} = useSession() 27 - const getToken = useServiceAuthToken({ 26 + _: I18n['_'] 27 + }) { 28 + if (signal.aborted) { 29 + throw new AbortError() 30 + } 31 + await getVideoUploadLimits(agent, _) 32 + 33 + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 34 + did, 35 + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 36 + }) 37 + 38 + if (signal.aborted) { 39 + throw new AbortError() 40 + } 41 + const token = await getServiceAuthToken({ 42 + agent, 28 43 lxm: 'com.atproto.repo.uploadBlob', 29 44 exp: Date.now() / 1000 + 60 * 30, // 30 minutes 30 45 }) 31 - const checkLimits = useVideoUploadLimits() 32 - const {_} = useLingui() 46 + const uploadTask = createUploadTask( 47 + uri, 48 + video.uri, 49 + { 50 + headers: { 51 + 'content-type': video.mimeType, 52 + Authorization: `Bearer ${token}`, 53 + }, 54 + httpMethod: 'POST', 55 + uploadType: FileSystemUploadType.BINARY_CONTENT, 56 + }, 57 + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), 58 + ) 33 59 34 - return useMutation({ 35 - mutationKey: ['video', 'upload'], 36 - mutationFn: cancelable(async (video: CompressedVideo) => { 37 - await checkLimits() 60 + if (signal.aborted) { 61 + throw new AbortError() 62 + } 63 + const res = await uploadTask.uploadAsync() 38 64 39 - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 40 - did: currentAccount!.did, 41 - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 42 - }) 65 + if (!res?.body) { 66 + throw new Error('No response') 67 + } 43 68 44 - const uploadTask = createUploadTask( 45 - uri, 46 - video.uri, 47 - { 48 - headers: { 49 - 'content-type': video.mimeType, 50 - Authorization: `Bearer ${await getToken()}`, 51 - }, 52 - httpMethod: 'POST', 53 - uploadType: FileSystemUploadType.BINARY_CONTENT, 54 - }, 55 - p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), 56 - ) 57 - const res = await uploadTask.uploadAsync() 69 + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 58 70 59 - if (!res?.body) { 60 - throw new Error('No response') 61 - } 71 + if (!responseBody.jobId) { 72 + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) 73 + } 62 74 63 - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 64 - 65 - if (!responseBody.jobId) { 66 - throw new ServerError( 67 - responseBody.error || _(msg`Failed to upload video`), 68 - ) 69 - } 70 - 71 - return responseBody 72 - }, signal), 73 - onError, 74 - onSuccess, 75 - }) 75 + if (signal.aborted) { 76 + throw new AbortError() 77 + } 78 + return responseBody 76 79 }
+73 -64
src/state/queries/video/video-upload.web.ts
··· 1 1 import {AppBskyVideoDefs} from '@atproto/api' 2 + import {BskyAgent} from '@atproto/api' 3 + import {I18n} from '@lingui/core' 2 4 import {msg} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 4 - import {useMutation} from '@tanstack/react-query' 5 5 import {nanoid} from 'nanoid/non-secure' 6 6 7 - import {cancelable} from '#/lib/async/cancelable' 7 + import {AbortError} from '#/lib/async/cancelable' 8 8 import {ServerError} from '#/lib/media/video/errors' 9 9 import {CompressedVideo} from '#/lib/media/video/types' 10 10 import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' 11 - import {useSession} from '#/state/session' 12 - import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' 11 + import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' 13 12 14 - export const useUploadVideoMutation = ({ 15 - onSuccess, 16 - onError, 13 + export async function uploadVideo({ 14 + video, 15 + agent, 16 + did, 17 17 setProgress, 18 18 signal, 19 + _, 19 20 }: { 20 - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 21 - onError: (e: any) => void 21 + video: CompressedVideo 22 + agent: BskyAgent 23 + did: string 22 24 setProgress: (progress: number) => void 23 25 signal: AbortSignal 24 - }) => { 25 - const {currentAccount} = useSession() 26 - const getToken = useServiceAuthToken({ 26 + _: I18n['_'] 27 + }) { 28 + if (signal.aborted) { 29 + throw new AbortError() 30 + } 31 + await getVideoUploadLimits(agent, _) 32 + 33 + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 34 + did, 35 + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 36 + }) 37 + 38 + let bytes = video.bytes 39 + if (!bytes) { 40 + if (signal.aborted) { 41 + throw new AbortError() 42 + } 43 + bytes = await fetch(video.uri).then(res => res.arrayBuffer()) 44 + } 45 + 46 + if (signal.aborted) { 47 + throw new AbortError() 48 + } 49 + const token = await getServiceAuthToken({ 50 + agent, 27 51 lxm: 'com.atproto.repo.uploadBlob', 28 52 exp: Date.now() / 1000 + 60 * 30, // 30 minutes 29 53 }) 30 - const checkLimits = useVideoUploadLimits() 31 - const {_} = useLingui() 32 54 33 - return useMutation({ 34 - mutationKey: ['video', 'upload'], 35 - mutationFn: cancelable(async (video: CompressedVideo) => { 36 - await checkLimits() 37 - 38 - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 39 - did: currentAccount!.did, 40 - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, 55 + if (signal.aborted) { 56 + throw new AbortError() 57 + } 58 + const xhr = new XMLHttpRequest() 59 + const res = await new Promise<AppBskyVideoDefs.JobStatus>( 60 + (resolve, reject) => { 61 + xhr.upload.addEventListener('progress', e => { 62 + const progress = e.loaded / e.total 63 + setProgress(progress) 41 64 }) 42 - 43 - let bytes = video.bytes 44 - if (!bytes) { 45 - bytes = await fetch(video.uri).then(res => res.arrayBuffer()) 65 + xhr.onloadend = () => { 66 + if (signal.aborted) { 67 + reject(new AbortError()) 68 + } else if (xhr.readyState === 4) { 69 + const uploadRes = JSON.parse( 70 + xhr.responseText, 71 + ) as AppBskyVideoDefs.JobStatus 72 + resolve(uploadRes) 73 + } else { 74 + reject(new ServerError(_(msg`Failed to upload video`))) 75 + } 46 76 } 47 - 48 - const token = await getToken() 49 - 50 - const xhr = new XMLHttpRequest() 51 - const res = await new Promise<AppBskyVideoDefs.JobStatus>( 52 - (resolve, reject) => { 53 - xhr.upload.addEventListener('progress', e => { 54 - const progress = e.loaded / e.total 55 - setProgress(progress) 56 - }) 57 - xhr.onloadend = () => { 58 - if (xhr.readyState === 4) { 59 - const uploadRes = JSON.parse( 60 - xhr.responseText, 61 - ) as AppBskyVideoDefs.JobStatus 62 - resolve(uploadRes) 63 - } else { 64 - reject(new ServerError(_(msg`Failed to upload video`))) 65 - } 66 - } 67 - xhr.onerror = () => { 68 - reject(new ServerError(_(msg`Failed to upload video`))) 69 - } 70 - xhr.open('POST', uri) 71 - xhr.setRequestHeader('Content-Type', video.mimeType) 72 - xhr.setRequestHeader('Authorization', `Bearer ${token}`) 73 - xhr.send(bytes) 74 - }, 75 - ) 76 - 77 - if (!res.jobId) { 78 - throw new ServerError(res.error || _(msg`Failed to upload video`)) 77 + xhr.onerror = () => { 78 + reject(new ServerError(_(msg`Failed to upload video`))) 79 79 } 80 + xhr.open('POST', uri) 81 + xhr.setRequestHeader('Content-Type', video.mimeType) 82 + xhr.setRequestHeader('Authorization', `Bearer ${token}`) 83 + xhr.send(bytes) 84 + }, 85 + ) 80 86 81 - return res 82 - }, signal), 83 - onError, 84 - onSuccess, 85 - }) 87 + if (!res.jobId) { 88 + throw new ServerError(res.error || _(msg`Failed to upload video`)) 89 + } 90 + 91 + if (signal.aborted) { 92 + throw new AbortError() 93 + } 94 + return res 86 95 }
+358 -288
src/state/queries/video/video.ts
··· 1 - import React, {useCallback, useEffect} from 'react' 2 1 import {ImagePickerAsset} from 'expo-image-picker' 3 - import {AppBskyVideoDefs, BlobRef} from '@atproto/api' 2 + import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' 3 + import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' 4 + import {I18n} from '@lingui/core' 4 5 import {msg} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 7 6 8 7 import {AbortError} from '#/lib/async/cancelable' 9 - import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' 8 + import {compressVideo} from '#/lib/media/video/compress' 10 9 import { 11 10 ServerError, 12 11 UploadLimitError, ··· 14 13 } from '#/lib/media/video/errors' 15 14 import {CompressedVideo} from '#/lib/media/video/types' 16 15 import {logger} from '#/logger' 17 - import {isWeb} from '#/platform/detection' 18 - import {useCompressVideoMutation} from '#/state/queries/video/compress-video' 19 - import {useVideoAgent} from '#/state/queries/video/util' 20 - import {useUploadVideoMutation} from '#/state/queries/video/video-upload' 16 + import {createVideoAgent} from '#/state/queries/video/util' 17 + import {uploadVideo} from '#/state/queries/video/video-upload' 21 18 22 - type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' 19 + type Action = 20 + | {type: 'to_idle'; nextController: AbortController} 21 + | { 22 + type: 'idle_to_compressing' 23 + asset: ImagePickerAsset 24 + signal: AbortSignal 25 + } 26 + | { 27 + type: 'compressing_to_uploading' 28 + video: CompressedVideo 29 + signal: AbortSignal 30 + } 31 + | { 32 + type: 'uploading_to_processing' 33 + jobId: string 34 + signal: AbortSignal 35 + } 36 + | {type: 'to_error'; error: string; signal: AbortSignal} 37 + | { 38 + type: 'to_done' 39 + blobRef: BlobRef 40 + signal: AbortSignal 41 + } 42 + | {type: 'update_progress'; progress: number; signal: AbortSignal} 43 + | { 44 + type: 'update_dimensions' 45 + width: number 46 + height: number 47 + signal: AbortSignal 48 + } 49 + | { 50 + type: 'update_job_status' 51 + jobStatus: AppBskyVideoDefs.JobStatus 52 + signal: AbortSignal 53 + } 23 54 24 - type Action = 25 - | {type: 'SetStatus'; status: Status} 26 - | {type: 'SetProgress'; progress: number} 27 - | {type: 'SetError'; error: string | undefined} 28 - | {type: 'Reset'} 29 - | {type: 'SetAsset'; asset: ImagePickerAsset} 30 - | {type: 'SetDimensions'; width: number; height: number} 31 - | {type: 'SetVideo'; video: CompressedVideo} 32 - | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} 33 - | {type: 'SetComplete'; blobRef: BlobRef} 55 + type IdleState = { 56 + status: 'idle' 57 + progress: 0 58 + abortController: AbortController 59 + asset?: undefined 60 + video?: undefined 61 + jobId?: undefined 62 + pendingPublish?: undefined 63 + } 34 64 35 - export interface State { 36 - status: Status 37 - progress: number 38 - asset?: ImagePickerAsset 65 + type ErrorState = { 66 + status: 'error' 67 + progress: 100 68 + abortController: AbortController 69 + asset: ImagePickerAsset | null 39 70 video: CompressedVideo | null 40 - jobStatus?: AppBskyVideoDefs.JobStatus 41 - blobRef?: BlobRef 42 - error?: string 71 + jobId: string | null 72 + error: string 73 + pendingPublish?: undefined 74 + } 75 + 76 + type CompressingState = { 77 + status: 'compressing' 78 + progress: number 79 + abortController: AbortController 80 + asset: ImagePickerAsset 81 + video?: undefined 82 + jobId?: undefined 83 + pendingPublish?: undefined 84 + } 85 + 86 + type UploadingState = { 87 + status: 'uploading' 88 + progress: number 89 + abortController: AbortController 90 + asset: ImagePickerAsset 91 + video: CompressedVideo 92 + jobId?: undefined 93 + pendingPublish?: undefined 94 + } 95 + 96 + type ProcessingState = { 97 + status: 'processing' 98 + progress: number 43 99 abortController: AbortController 44 - pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} 100 + asset: ImagePickerAsset 101 + video: CompressedVideo 102 + jobId: string 103 + jobStatus: AppBskyVideoDefs.JobStatus | null 104 + pendingPublish?: undefined 105 + } 106 + 107 + type DoneState = { 108 + status: 'done' 109 + progress: 100 110 + abortController: AbortController 111 + asset: ImagePickerAsset 112 + video: CompressedVideo 113 + jobId?: undefined 114 + pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} 45 115 } 46 116 47 - export type VideoUploadDispatch = (action: Action) => void 117 + export type State = 118 + | IdleState 119 + | ErrorState 120 + | CompressingState 121 + | UploadingState 122 + | ProcessingState 123 + | DoneState 48 124 49 - function reducer(queryClient: QueryClient) { 50 - return (state: State, action: Action): State => { 51 - let updatedState = state 52 - if (action.type === 'SetStatus') { 53 - updatedState = {...state, status: action.status} 54 - } else if (action.type === 'SetProgress') { 55 - updatedState = {...state, progress: action.progress} 56 - } else if (action.type === 'SetError') { 57 - updatedState = {...state, error: action.error} 58 - } else if (action.type === 'Reset') { 59 - state.abortController.abort() 60 - queryClient.cancelQueries({ 61 - queryKey: ['video'], 62 - }) 63 - updatedState = { 64 - status: 'idle', 125 + export function createVideoState( 126 + abortController: AbortController = new AbortController(), 127 + ): IdleState { 128 + return { 129 + status: 'idle', 130 + progress: 0, 131 + abortController, 132 + } 133 + } 134 + 135 + export function videoReducer(state: State, action: Action): State { 136 + if (action.type === 'to_idle') { 137 + return createVideoState(action.nextController) 138 + } 139 + if (action.signal.aborted || action.signal !== state.abortController.signal) { 140 + // This action is stale and the process that spawned it is no longer relevant. 141 + return state 142 + } 143 + if (action.type === 'to_error') { 144 + return { 145 + status: 'error', 146 + progress: 100, 147 + abortController: state.abortController, 148 + error: action.error, 149 + asset: state.asset ?? null, 150 + video: state.video ?? null, 151 + jobId: state.jobId ?? null, 152 + } 153 + } else if (action.type === 'update_progress') { 154 + if (state.status === 'compressing' || state.status === 'uploading') { 155 + return { 156 + ...state, 157 + progress: action.progress, 158 + } 159 + } 160 + } else if (action.type === 'idle_to_compressing') { 161 + if (state.status === 'idle') { 162 + return { 163 + status: 'compressing', 65 164 progress: 0, 66 - video: null, 67 - blobRef: undefined, 68 - abortController: new AbortController(), 165 + abortController: state.abortController, 166 + asset: action.asset, 69 167 } 70 - } else if (action.type === 'SetAsset') { 71 - updatedState = { 168 + } 169 + } else if (action.type === 'update_dimensions') { 170 + if (state.asset) { 171 + return { 72 172 ...state, 73 - asset: action.asset, 74 - status: 'compressing', 75 - error: undefined, 173 + asset: {...state.asset, width: action.width, height: action.height}, 174 + } 175 + } 176 + } else if (action.type === 'compressing_to_uploading') { 177 + if (state.status === 'compressing') { 178 + return { 179 + status: 'uploading', 180 + progress: 0, 181 + abortController: state.abortController, 182 + asset: state.asset, 183 + video: action.video, 76 184 } 77 - } else if (action.type === 'SetDimensions') { 78 - updatedState = { 185 + } 186 + return state 187 + } else if (action.type === 'uploading_to_processing') { 188 + if (state.status === 'uploading') { 189 + return { 190 + status: 'processing', 191 + progress: 0, 192 + abortController: state.abortController, 193 + asset: state.asset, 194 + video: state.video, 195 + jobId: action.jobId, 196 + jobStatus: null, 197 + } 198 + } 199 + } else if (action.type === 'update_job_status') { 200 + if (state.status === 'processing') { 201 + return { 79 202 ...state, 80 - asset: state.asset 81 - ? {...state.asset, width: action.width, height: action.height} 82 - : undefined, 203 + jobStatus: action.jobStatus, 204 + progress: 205 + action.jobStatus.progress !== undefined 206 + ? action.jobStatus.progress / 100 207 + : state.progress, 83 208 } 84 - } else if (action.type === 'SetVideo') { 85 - updatedState = {...state, video: action.video, status: 'uploading'} 86 - } else if (action.type === 'SetJobStatus') { 87 - updatedState = {...state, jobStatus: action.jobStatus} 88 - } else if (action.type === 'SetComplete') { 89 - updatedState = { 90 - ...state, 209 + } 210 + } else if (action.type === 'to_done') { 211 + if (state.status === 'processing') { 212 + return { 213 + status: 'done', 214 + progress: 100, 215 + abortController: state.abortController, 216 + asset: state.asset, 217 + video: state.video, 91 218 pendingPublish: { 92 219 blobRef: action.blobRef, 93 220 mutableProcessed: false, 94 221 }, 95 - status: 'done', 96 222 } 97 223 } 98 - return updatedState 99 224 } 225 + console.error( 226 + 'Unexpected video action (' + 227 + action.type + 228 + ') while in ' + 229 + state.status + 230 + ' state', 231 + ) 232 + return state 100 233 } 101 234 102 - export function useUploadVideo({ 103 - setStatus, 104 - initialVideoUri, 105 - }: { 106 - setStatus: (status: string) => void 107 - onSuccess: () => void 108 - initialVideoUri?: string 109 - }) { 110 - const {_} = useLingui() 111 - const queryClient = useQueryClient() 112 - const [state, dispatch] = React.useReducer(reducer(queryClient), { 113 - status: 'idle', 114 - progress: 0, 115 - video: null, 116 - abortController: new AbortController(), 117 - }) 118 - 119 - const {setJobId} = useUploadStatusQuery({ 120 - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { 121 - // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user 122 - // Leaving it for now though 123 - dispatch({ 124 - type: 'SetJobStatus', 125 - jobStatus: status, 126 - }) 127 - setStatus(status.state.toString()) 128 - }, 129 - onSuccess: blobRef => { 130 - dispatch({ 131 - type: 'SetComplete', 132 - blobRef, 133 - }) 134 - }, 135 - onError: useCallback( 136 - error => { 137 - logger.error('Error processing video', {safeMessage: error}) 138 - dispatch({ 139 - type: 'SetError', 140 - error: _(msg`Video failed to process`), 141 - }) 142 - }, 143 - [_], 144 - ), 145 - }) 235 + function trunc2dp(num: number) { 236 + return Math.trunc(num * 100) / 100 237 + } 146 238 147 - const {mutate: onVideoCompressed} = useUploadVideoMutation({ 148 - onSuccess: response => { 149 - dispatch({ 150 - type: 'SetStatus', 151 - status: 'processing', 152 - }) 153 - setJobId(response.jobId) 154 - }, 155 - onError: e => { 156 - if (e instanceof AbortError) { 157 - return 158 - } else if (e instanceof ServerError || e instanceof UploadLimitError) { 159 - let message 160 - // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 161 - switch (e.message) { 162 - case 'User is not allowed to upload videos': 163 - message = _(msg`You are not allowed to upload videos.`) 164 - break 165 - case 'Uploading is disabled at the moment': 166 - message = _( 167 - msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, 168 - ) 169 - break 170 - case "Failed to get user's upload stats": 171 - message = _( 172 - msg`We were unable to determine if you are allowed to upload videos. Please try again.`, 173 - ) 174 - break 175 - case 'User has exceeded daily upload bytes limit': 176 - message = _( 177 - msg`You've reached your daily limit for video uploads (too many bytes)`, 178 - ) 179 - break 180 - case 'User has exceeded daily upload videos limit': 181 - message = _( 182 - msg`You've reached your daily limit for video uploads (too many videos)`, 183 - ) 184 - break 185 - case 'Account is not old enough to upload videos': 186 - message = _( 187 - msg`Your account is not yet old enough to upload videos. Please try again later.`, 188 - ) 189 - break 190 - default: 191 - message = e.message 192 - break 193 - } 194 - dispatch({ 195 - type: 'SetError', 196 - error: message, 197 - }) 198 - } else { 199 - dispatch({ 200 - type: 'SetError', 201 - error: _(msg`An error occurred while uploading the video.`), 202 - }) 203 - } 204 - logger.error('Error uploading video', {safeMessage: e}) 205 - }, 206 - setProgress: p => { 207 - dispatch({type: 'SetProgress', progress: p}) 208 - }, 209 - signal: state.abortController.signal, 239 + export async function processVideo( 240 + asset: ImagePickerAsset, 241 + dispatch: (action: Action) => void, 242 + agent: BskyAgent, 243 + did: string, 244 + signal: AbortSignal, 245 + _: I18n['_'], 246 + ) { 247 + dispatch({ 248 + type: 'idle_to_compressing', 249 + asset, 250 + signal, 210 251 }) 211 252 212 - const {mutate: onSelectVideo} = useCompressVideoMutation({ 213 - onProgress: p => { 214 - dispatch({type: 'SetProgress', progress: p}) 215 - }, 216 - onSuccess: (video: CompressedVideo) => { 253 + let video: CompressedVideo | undefined 254 + try { 255 + video = await compressVideo(asset, { 256 + onProgress: num => { 257 + dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) 258 + }, 259 + signal, 260 + }) 261 + } catch (e) { 262 + const message = getCompressErrorMessage(e, _) 263 + if (message !== null) { 217 264 dispatch({ 218 - type: 'SetVideo', 219 - video, 265 + type: 'to_error', 266 + error: message, 267 + signal, 220 268 }) 221 - onVideoCompressed(video) 222 - }, 223 - onError: e => { 224 - if (e instanceof AbortError) { 225 - return 226 - } else if (e instanceof VideoTooLargeError) { 227 - dispatch({ 228 - type: 'SetError', 229 - error: _(msg`The selected video is larger than 50MB.`), 230 - }) 231 - } else { 232 - dispatch({ 233 - type: 'SetError', 234 - error: _(msg`An error occurred while compressing the video.`), 235 - }) 236 - logger.error('Error compressing video', {safeMessage: e}) 237 - } 238 - }, 239 - signal: state.abortController.signal, 269 + } 270 + return 271 + } 272 + dispatch({ 273 + type: 'compressing_to_uploading', 274 + video, 275 + signal, 240 276 }) 241 277 242 - const selectVideo = React.useCallback( 243 - (asset: ImagePickerAsset) => { 244 - // compression step on native converts to mp4, so no need to check there 245 - if (isWeb) { 246 - const mimeType = getMimeType(asset) 247 - if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { 248 - throw new Error(_(msg`Unsupported video type: ${mimeType}`)) 249 - } 250 - } 251 - 278 + let uploadResponse: AppBskyVideoDefs.JobStatus | undefined 279 + try { 280 + uploadResponse = await uploadVideo({ 281 + video, 282 + agent, 283 + did, 284 + signal, 285 + _, 286 + setProgress: p => { 287 + dispatch({type: 'update_progress', progress: p, signal}) 288 + }, 289 + }) 290 + } catch (e) { 291 + const message = getUploadErrorMessage(e, _) 292 + if (message !== null) { 252 293 dispatch({ 253 - type: 'SetAsset', 254 - asset, 294 + type: 'to_error', 295 + error: message, 296 + signal, 255 297 }) 256 - onSelectVideo(asset) 257 - }, 258 - [_, onSelectVideo], 259 - ) 260 - 261 - const clearVideo = () => { 262 - dispatch({type: 'Reset'}) 298 + } 299 + return 263 300 } 264 301 265 - const updateVideoDimensions = useCallback((width: number, height: number) => { 266 - dispatch({ 267 - type: 'SetDimensions', 268 - width, 269 - height, 270 - }) 271 - }, []) 302 + const jobId = uploadResponse.jobId 303 + dispatch({ 304 + type: 'uploading_to_processing', 305 + jobId, 306 + signal, 307 + }) 272 308 273 - // Whenever we receive an initial video uri, we should immediately run compression if necessary 274 - useEffect(() => { 275 - if (initialVideoUri) { 276 - selectVideo({uri: initialVideoUri} as ImagePickerAsset) 309 + let pollFailures = 0 310 + while (true) { 311 + if (signal.aborted) { 312 + return // Exit async loop 277 313 } 278 - }, [initialVideoUri, selectVideo]) 279 314 280 - return { 281 - state, 282 - dispatch, 283 - selectVideo, 284 - clearVideo, 285 - updateVideoDimensions, 286 - } 287 - } 315 + const videoAgent = createVideoAgent() 316 + let status: JobStatus | undefined 317 + let blob: BlobRef | undefined 318 + try { 319 + const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) 320 + status = response.data.jobStatus 321 + pollFailures = 0 288 322 289 - const useUploadStatusQuery = ({ 290 - onStatusChange, 291 - onSuccess, 292 - onError, 293 - }: { 294 - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void 295 - onSuccess: (blobRef: BlobRef) => void 296 - onError: (error: Error) => void 297 - }) => { 298 - const videoAgent = useVideoAgent() 299 - const [enabled, setEnabled] = React.useState(true) 300 - const [jobId, setJobId] = React.useState<string>() 301 - 302 - const {error} = useQuery({ 303 - queryKey: ['video', 'upload status', jobId], 304 - queryFn: async () => { 305 - if (!jobId) return // this won't happen, can ignore 306 - 307 - const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) 308 - const status = data.jobStatus 309 323 if (status.state === 'JOB_STATE_COMPLETED') { 310 - setEnabled(false) 311 - if (!status.blob) 324 + blob = status.blob 325 + if (!blob) { 312 326 throw new Error('Job completed, but did not return a blob') 313 - onSuccess(status.blob) 327 + } 314 328 } else if (status.state === 'JOB_STATE_FAILED') { 315 329 throw new Error(status.error ?? 'Job failed to process') 316 330 } 317 - onStatusChange(status) 318 - return status 319 - }, 320 - enabled: Boolean(jobId && enabled), 321 - refetchInterval: 1500, 322 - }) 331 + } catch (e) { 332 + if (!status) { 333 + pollFailures++ 334 + if (pollFailures < 50) { 335 + await new Promise(resolve => setTimeout(resolve, 5000)) 336 + continue // Continue async loop 337 + } 338 + } 339 + 340 + logger.error('Error processing video', {safeMessage: e}) 341 + dispatch({ 342 + type: 'to_error', 343 + error: _(msg`Video failed to process`), 344 + signal, 345 + }) 346 + return // Exit async loop 347 + } 348 + 349 + if (blob) { 350 + dispatch({ 351 + type: 'to_done', 352 + blobRef: blob, 353 + signal, 354 + }) 355 + } else { 356 + dispatch({ 357 + type: 'update_job_status', 358 + jobStatus: status, 359 + signal, 360 + }) 361 + } 323 362 324 - useEffect(() => { 325 - if (error) { 326 - onError(error) 327 - setEnabled(false) 363 + if ( 364 + status.state !== 'JOB_STATE_COMPLETED' && 365 + status.state !== 'JOB_STATE_FAILED' 366 + ) { 367 + await new Promise(resolve => setTimeout(resolve, 1500)) 368 + continue // Continue async loop 328 369 } 329 - }, [error, onError]) 330 370 331 - return { 332 - setJobId: (_jobId: string) => { 333 - setJobId(_jobId) 334 - setEnabled(true) 335 - }, 371 + return // Exit async loop 336 372 } 337 373 } 338 374 339 - function getMimeType(asset: ImagePickerAsset) { 340 - if (isWeb) { 341 - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') 342 - if (!mimeType) { 343 - throw new Error('Could not determine mime type') 344 - } 345 - return mimeType 375 + function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { 376 + if (e instanceof AbortError) { 377 + return null 346 378 } 347 - if (!asset.mimeType) { 348 - throw new Error('Could not determine mime type') 379 + if (e instanceof VideoTooLargeError) { 380 + return _(msg`The selected video is larger than 50MB.`) 349 381 } 350 - return asset.mimeType 382 + logger.error('Error compressing video', {safeMessage: e}) 383 + return _(msg`An error occurred while compressing the video.`) 384 + } 385 + 386 + function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { 387 + if (e instanceof AbortError) { 388 + return null 389 + } 390 + logger.error('Error uploading video', {safeMessage: e}) 391 + if (e instanceof ServerError || e instanceof UploadLimitError) { 392 + // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 393 + switch (e.message) { 394 + case 'User is not allowed to upload videos': 395 + return _(msg`You are not allowed to upload videos.`) 396 + case 'Uploading is disabled at the moment': 397 + return _( 398 + msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, 399 + ) 400 + case "Failed to get user's upload stats": 401 + return _( 402 + msg`We were unable to determine if you are allowed to upload videos. Please try again.`, 403 + ) 404 + case 'User has exceeded daily upload bytes limit': 405 + return _( 406 + msg`You've reached your daily limit for video uploads (too many bytes)`, 407 + ) 408 + case 'User has exceeded daily upload videos limit': 409 + return _( 410 + msg`You've reached your daily limit for video uploads (too many videos)`, 411 + ) 412 + case 'Account is not old enough to upload videos': 413 + return _( 414 + msg`Your account is not yet old enough to upload videos. Please try again later.`, 415 + ) 416 + default: 417 + return e.message 418 + } 419 + } 420 + return _(msg`An error occurred while uploading the video.`) 351 421 }
+78 -46
src/view/com/composer/Composer.tsx
··· 36 36 ZoomOut, 37 37 } from 'react-native-reanimated' 38 38 import {useSafeAreaInsets} from 'react-native-safe-area-context' 39 + import {ImagePickerAsset} from 'expo-image-picker' 39 40 import { 40 41 AppBskyFeedDefs, 41 42 AppBskyFeedGetPostThread, ··· 82 83 import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' 83 84 import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' 84 85 import { 86 + createVideoState, 87 + processVideo, 85 88 State as VideoUploadState, 86 - useUploadVideo, 87 - VideoUploadDispatch, 89 + videoReducer, 88 90 } from '#/state/queries/video/video' 89 91 import {useAgent, useSession} from '#/state/session' 90 92 import {useComposerControls} from '#/state/shell/composer' ··· 147 149 }) => { 148 150 const {currentAccount} = useSession() 149 151 const agent = useAgent() 150 - const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) 152 + const currentDid = currentAccount!.did 153 + const {data: currentProfile} = useProfileQuery({did: currentDid}) 151 154 const {isModalActive} = useModals() 152 155 const {closeComposer} = useComposerControls() 153 156 const pal = usePalette('default') ··· 189 192 const [videoAltText, setVideoAltText] = useState('') 190 193 const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) 191 194 192 - const { 193 - selectVideo, 194 - clearVideo, 195 - state: videoUploadState, 196 - updateVideoDimensions, 197 - dispatch: videoUploadDispatch, 198 - } = useUploadVideo({ 199 - setStatus: setProcessingState, 200 - onSuccess: () => { 201 - if (publishOnUpload) { 202 - onPressPublish(true) 203 - } 195 + const [videoUploadState, videoDispatch] = useReducer( 196 + videoReducer, 197 + undefined, 198 + createVideoState, 199 + ) 200 + 201 + const selectVideo = React.useCallback( 202 + (asset: ImagePickerAsset) => { 203 + processVideo( 204 + asset, 205 + videoDispatch, 206 + agent, 207 + currentDid, 208 + videoUploadState.abortController.signal, 209 + _, 210 + ) 204 211 }, 205 - initialVideoUri: initVideoUri, 206 - }) 212 + [_, videoUploadState.abortController, videoDispatch, agent, currentDid], 213 + ) 214 + 215 + // Whenever we receive an initial video uri, we should immediately run compression if necessary 216 + useEffect(() => { 217 + if (initVideoUri) { 218 + selectVideo({uri: initVideoUri} as ImagePickerAsset) 219 + } 220 + }, [initVideoUri, selectVideo]) 221 + 222 + const clearVideo = React.useCallback(() => { 223 + videoUploadState.abortController.abort() 224 + videoDispatch({type: 'to_idle', nextController: new AbortController()}) 225 + }, [videoUploadState.abortController, videoDispatch]) 226 + 227 + const updateVideoDimensions = useCallback( 228 + (width: number, height: number) => { 229 + videoDispatch({ 230 + type: 'update_dimensions', 231 + width, 232 + height, 233 + signal: videoUploadState.abortController.signal, 234 + }) 235 + }, 236 + [videoUploadState.abortController], 237 + ) 238 + 207 239 const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) 208 240 209 241 const [publishOnUpload, setPublishOnUpload] = useState(false) ··· 400 432 postgate, 401 433 onStateChange: setProcessingState, 402 434 langs: toPostLanguages(langPrefs.postLanguage), 403 - video: videoUploadState.pendingPublish?.blobRef 404 - ? { 405 - blobRef: videoUploadState.pendingPublish.blobRef, 406 - altText: videoAltText, 407 - captions: captions, 408 - aspectRatio: videoUploadState.asset 409 - ? { 410 - width: videoUploadState.asset?.width, 411 - height: videoUploadState.asset?.height, 412 - } 413 - : undefined, 414 - } 415 - : undefined, 435 + video: 436 + videoUploadState.status === 'done' 437 + ? { 438 + blobRef: videoUploadState.pendingPublish.blobRef, 439 + altText: videoAltText, 440 + captions: captions, 441 + aspectRatio: { 442 + width: videoUploadState.asset.width, 443 + height: videoUploadState.asset.height, 444 + }, 445 + } 446 + : undefined, 416 447 }) 417 448 ).uri 418 449 try { ··· 694 725 error={error} 695 726 videoUploadState={videoUploadState} 696 727 clearError={() => setError('')} 697 - videoUploadDispatch={videoUploadDispatch} 728 + clearVideo={clearVideo} 698 729 /> 699 730 </Animated.View> 700 731 <Animated.ScrollView ··· 1083 1114 error: standardError, 1084 1115 videoUploadState, 1085 1116 clearError, 1086 - videoUploadDispatch, 1117 + clearVideo, 1087 1118 }: { 1088 1119 error: string 1089 1120 videoUploadState: VideoUploadState 1090 1121 clearError: () => void 1091 - videoUploadDispatch: VideoUploadDispatch 1122 + clearVideo: () => void 1092 1123 }) { 1093 1124 const t = useTheme() 1094 1125 const {_} = useLingui() 1095 1126 1096 1127 const videoError = 1097 - videoUploadState.status !== 'idle' ? videoUploadState.error : undefined 1128 + videoUploadState.status === 'error' ? videoUploadState.error : undefined 1098 1129 const error = standardError || videoError 1099 1130 1100 1131 const onClearError = () => { 1101 1132 if (standardError) { 1102 1133 clearError() 1103 1134 } else { 1104 - videoUploadDispatch({type: 'Reset'}) 1135 + clearVideo() 1105 1136 } 1106 1137 } 1107 1138 ··· 1136 1167 <ButtonIcon icon={X} /> 1137 1168 </Button> 1138 1169 </View> 1139 - {videoError && videoUploadState.jobStatus?.jobId && ( 1170 + {videoError && videoUploadState.jobId && ( 1140 1171 <NewText 1141 1172 style={[ 1142 1173 {paddingLeft: 28}, ··· 1145 1176 a.leading_snug, 1146 1177 t.atoms.text_contrast_low, 1147 1178 ]}> 1148 - <Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans> 1179 + <Trans>Job ID: {videoUploadState.jobId}</Trans> 1149 1180 </NewText> 1150 1181 )} 1151 1182 </View> ··· 1174 1205 function VideoUploadToolbar({state}: {state: VideoUploadState}) { 1175 1206 const t = useTheme() 1176 1207 const {_} = useLingui() 1177 - const progress = state.jobStatus?.progress 1178 - ? state.jobStatus.progress / 100 1179 - : state.progress 1208 + const progress = state.progress 1180 1209 const shouldRotate = 1181 1210 state.status === 'processing' && (progress === 0 || progress === 1) 1182 1211 let wheelProgress = shouldRotate ? 0.33 : progress ··· 1212 1241 case 'processing': 1213 1242 text = _('Processing video...') 1214 1243 break 1244 + case 'error': 1245 + text = _('Error') 1246 + wheelProgress = 100 1247 + break 1215 1248 case 'done': 1216 1249 text = _('Video uploaded') 1217 1250 break 1218 1251 } 1219 1252 1220 - if (state.error) { 1221 - text = _('Error') 1222 - wheelProgress = 100 1223 - } 1224 - 1225 1253 return ( 1226 1254 <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> 1227 1255 <Animated.View style={[animatedStyle]}> ··· 1229 1257 size={30} 1230 1258 borderWidth={1} 1231 1259 borderColor={t.atoms.border_contrast_low.borderColor} 1232 - color={state.error ? t.palette.negative_500 : t.palette.primary_500} 1260 + color={ 1261 + state.status === 'error' 1262 + ? t.palette.negative_500 1263 + : t.palette.primary_500 1264 + } 1233 1265 progress={wheelProgress} 1234 1266 /> 1235 1267 </Animated.View>
+36 -11
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 9 9 import {msg} from '@lingui/macro' 10 10 import {useLingui} from '@lingui/react' 11 11 12 + import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' 13 + import {BSKY_SERVICE} from '#/lib/constants' 12 14 import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' 15 + import {getHostnameFromUrl} from '#/lib/strings/url-helpers' 16 + import {isWeb} from '#/platform/detection' 13 17 import {isNative} from '#/platform/detection' 14 18 import {useModalControls} from '#/state/modals' 15 19 import {useSession} from '#/state/session' 16 - import {BSKY_SERVICE} from 'lib/constants' 17 - import {getHostnameFromUrl} from 'lib/strings/url-helpers' 18 20 import {atoms as a, useTheme} from '#/alf' 19 21 import {Button} from '#/components/Button' 20 22 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' ··· 58 60 UIImagePickerPreferredAssetRepresentationMode.Current, 59 61 }) 60 62 if (response.assets && response.assets.length > 0) { 61 - if (isNative) { 62 - if (typeof response.assets[0].duration !== 'number') 63 - throw Error('Asset is not a video') 64 - if (response.assets[0].duration > VIDEO_MAX_DURATION) { 65 - setError(_(msg`Videos must be less than 60 seconds long`)) 66 - return 63 + const asset = response.assets[0] 64 + try { 65 + if (isWeb) { 66 + // compression step on native converts to mp4, so no need to check there 67 + const mimeType = getMimeType(asset) 68 + if ( 69 + !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) 70 + ) { 71 + throw Error(_(msg`Unsupported video type: ${mimeType}`)) 72 + } 73 + } else { 74 + if (typeof asset.duration !== 'number') { 75 + throw Error('Asset is not a video') 76 + } 77 + if (asset.duration > VIDEO_MAX_DURATION) { 78 + throw Error(_(msg`Videos must be less than 60 seconds long`)) 79 + } 67 80 } 68 - } 69 - try { 70 - onSelectVideo(response.assets[0]) 81 + onSelectVideo(asset) 71 82 } catch (err) { 72 83 if (err instanceof Error) { 73 84 setError(err.message) ··· 132 143 /> 133 144 ) 134 145 } 146 + 147 + function getMimeType(asset: ImagePickerAsset) { 148 + if (isWeb) { 149 + const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') 150 + if (!mimeType) { 151 + throw new Error('Could not determine mime type') 152 + } 153 + return mimeType 154 + } 155 + if (!asset.mimeType) { 156 + throw new Error('Could not determine mime type') 157 + } 158 + return asset.mimeType 159 + }