diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts deleted file mode 100644 index cefbf94066..0000000000 --- a/src/state/queries/video/compress-video.ts +++ /dev/null @@ -1,39 +0,0 @@ -import {ImagePickerAsset} from 'expo-image-picker' -import {useMutation} from '@tanstack/react-query' - -import {cancelable} from '#/lib/async/cancelable' -import {CompressedVideo} from '#/lib/media/video/types' -import {compressVideo} from 'lib/media/video/compress' - -export function useCompressVideoMutation({ - onProgress, - onSuccess, - onError, - signal, -}: { - onProgress: (progress: number) => void - onError: (e: any) => void - onSuccess: (video: CompressedVideo) => void - signal: AbortSignal -}) { - return useMutation({ - mutationKey: ['video', 'compress'], - mutationFn: cancelable( - (asset: ImagePickerAsset) => - compressVideo(asset, { - onProgress: num => onProgress(trunc2dp(num)), - signal, - }), - signal, - ), - onError, - onSuccess, - onMutate: () => { - onProgress(0) - }, - }) -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts index 2c1298ab63..87b422c2c9 100644 --- a/src/state/queries/video/util.ts +++ b/src/state/queries/video/util.ts @@ -1,4 +1,3 @@ -import {useMemo} from 'react' import {AtpAgent} from '@atproto/api' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' @@ -17,12 +16,10 @@ export const createVideoEndpointUrl = ( return url.href } -export function useVideoAgent() { - return useMemo(() => { - return new AtpAgent({ - service: VIDEO_SERVICE, - }) - }, []) +export function createVideoAgent() { + return new AtpAgent({ + service: VIDEO_SERVICE, + }) } export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts index 6b633bf213..8c217eadcf 100644 --- a/src/state/queries/video/video-upload.shared.ts +++ b/src/state/queries/video/video-upload.shared.ts @@ -1,73 +1,61 @@ -import {useCallback} from 'react' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' import {VIDEO_SERVICE_DID} from '#/lib/constants' import {UploadLimitError} from '#/lib/media/video/errors' import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' -import {useAgent} from '#/state/session' -import {useVideoAgent} from './util' +import {createVideoAgent} from './util' -export function useServiceAuthToken({ +export async function getServiceAuthToken({ + agent, aud, lxm, exp, }: { + agent: BskyAgent aud?: string lxm: string exp?: number }) { - const agent = useAgent() - - return useCallback(async () => { - const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) - - if (!pdsAud) { - throw new Error('Agent does not have a PDS URL') - } - - const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ - aud: aud ?? pdsAud, - lxm, - exp, - }) - - return serviceAuth.token - }, [agent, aud, lxm, exp]) + const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) + if (!pdsAud) { + throw new Error('Agent does not have a PDS URL') + } + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ + aud: aud ?? pdsAud, + lxm, + exp, + }) + return serviceAuth.token } -export function useVideoUploadLimits() { - const agent = useVideoAgent() - const getToken = useServiceAuthToken({ +export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { + const token = await getServiceAuthToken({ + agent, lxm: 'app.bsky.video.getUploadLimits', aud: VIDEO_SERVICE_DID, }) - const {_} = useLingui() - - return useCallback(async () => { - const {data: limits} = await agent.app.bsky.video - .getUploadLimits( - {}, - {headers: {Authorization: `Bearer ${await getToken()}`}}, - ) - .catch(err => { - if (err instanceof Error) { - throw new UploadLimitError(err.message) - } else { - throw err - } - }) - - if (!limits.canUpload) { - if (limits.message) { - throw new UploadLimitError(limits.message) + const videoAgent = createVideoAgent() + const {data: limits} = await videoAgent.app.bsky.video + .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) + .catch(err => { + if (err instanceof Error) { + throw new UploadLimitError(err.message) } else { - throw new UploadLimitError( - _( - msg`You have temporarily reached the limit for video uploads. Please try again later.`, - ), - ) + throw err } + }) + + if (!limits.canUpload) { + if (limits.message) { + throw new UploadLimitError(limits.message) + } else { + throw new UploadLimitError( + _( + msg`You have temporarily reached the limit for video uploads. Please try again later.`, + ), + ) } - }, [agent, _, getToken]) + } } diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts index 170b538901..46f24a58b1 100644 --- a/src/state/queries/video/video-upload.ts +++ b/src/state/queries/video/video-upload.ts @@ -1,76 +1,79 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system' -import {AppBskyVideoDefs} from '@atproto/api' +import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {cancelable} from '#/lib/async/cancelable' +import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {useSession} from '#/state/session' -import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) - const checkLimits = useVideoUploadLimits() - const {_} = useLingui() + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'content-type': video.mimeType, + Authorization: `Bearer ${token}`, + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), + ) - return useMutation({ - mutationKey: ['video', 'upload'], - mutationFn: cancelable(async (video: CompressedVideo) => { - await checkLimits() + if (signal.aborted) { + throw new AbortError() + } + const res = await uploadTask.uploadAsync() - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did: currentAccount!.did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, - }) + if (!res?.body) { + throw new Error('No response') + } - const uploadTask = createUploadTask( - uri, - video.uri, - { - headers: { - 'content-type': video.mimeType, - Authorization: `Bearer ${await getToken()}`, - }, - httpMethod: 'POST', - uploadType: FileSystemUploadType.BINARY_CONTENT, - }, - p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), - ) - const res = await uploadTask.uploadAsync() + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - if (!res?.body) { - throw new Error('No response') - } + if (!responseBody.jobId) { + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + } - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - - if (!responseBody.jobId) { - throw new ServerError( - responseBody.error || _(msg`Failed to upload video`), - ) - } - - return responseBody - }, signal), - onError, - onSuccess, - }) + if (signal.aborted) { + throw new AbortError() + } + return responseBody } diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts index c93e206030..bbae641999 100644 --- a/src/state/queries/video/video-upload.web.ts +++ b/src/state/queries/video/video-upload.web.ts @@ -1,86 +1,95 @@ import {AppBskyVideoDefs} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useMutation} from '@tanstack/react-query' import {nanoid} from 'nanoid/non-secure' -import {cancelable} from '#/lib/async/cancelable' +import {AbortError} from '#/lib/async/cancelable' import {ServerError} from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util' -import {useSession} from '#/state/session' -import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared' +import {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' -export const useUploadVideoMutation = ({ - onSuccess, - onError, +export async function uploadVideo({ + video, + agent, + did, setProgress, signal, + _, }: { - onSuccess: (response: AppBskyVideoDefs.JobStatus) => void - onError: (e: any) => void + video: CompressedVideo + agent: BskyAgent + did: string setProgress: (progress: number) => void signal: AbortSignal -}) => { - const {currentAccount} = useSession() - const getToken = useServiceAuthToken({ + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + let bytes = video.bytes + if (!bytes) { + if (signal.aborted) { + throw new AbortError() + } + bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + } + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, lxm: 'com.atproto.repo.uploadBlob', exp: Date.now() / 1000 + 60 * 30, // 30 minutes }) - const checkLimits = useVideoUploadLimits() - const {_} = useLingui() - - return useMutation({ - mutationKey: ['video', 'upload'], - mutationFn: cancelable(async (video: CompressedVideo) => { - await checkLimits() - const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { - did: currentAccount!.did, - name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + if (signal.aborted) { + throw new AbortError() + } + const xhr = new XMLHttpRequest() + const res = await new Promise( + (resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) }) - - let bytes = video.bytes - if (!bytes) { - bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + xhr.onloadend = () => { + if (signal.aborted) { + reject(new AbortError()) + } else if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as AppBskyVideoDefs.JobStatus + resolve(uploadRes) + } else { + reject(new ServerError(_(msg`Failed to upload video`))) + } } - - const token = await getToken() - - const xhr = new XMLHttpRequest() - const res = await new Promise( - (resolve, reject) => { - xhr.upload.addEventListener('progress', e => { - const progress = e.loaded / e.total - setProgress(progress) - }) - xhr.onloadend = () => { - if (xhr.readyState === 4) { - const uploadRes = JSON.parse( - xhr.responseText, - ) as AppBskyVideoDefs.JobStatus - resolve(uploadRes) - } else { - reject(new ServerError(_(msg`Failed to upload video`))) - } - } - xhr.onerror = () => { - reject(new ServerError(_(msg`Failed to upload video`))) - } - xhr.open('POST', uri) - xhr.setRequestHeader('Content-Type', video.mimeType) - xhr.setRequestHeader('Authorization', `Bearer ${token}`) - xhr.send(bytes) - }, - ) - - if (!res.jobId) { - throw new ServerError(res.error || _(msg`Failed to upload video`)) + xhr.onerror = () => { + reject(new ServerError(_(msg`Failed to upload video`))) } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', video.mimeType) + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.send(bytes) + }, + ) - return res - }, signal), - onError, - onSuccess, - }) + if (!res.jobId) { + throw new ServerError(res.error || _(msg`Failed to upload video`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return res } diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index 0d77935da9..fabee6ad1a 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,12 +1,11 @@ -import React, {useCallback, useEffect} from 'react' import {ImagePickerAsset} from 'expo-image-picker' -import {AppBskyVideoDefs, BlobRef} from '@atproto/api' +import {AppBskyVideoDefs, BlobRef, BskyAgent} from '@atproto/api' +import {JobStatus} from '@atproto/api/dist/client/types/app/bsky/video/defs' +import {I18n} from '@lingui/core' import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {AbortError} from '#/lib/async/cancelable' -import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' +import {compressVideo} from '#/lib/media/video/compress' import { ServerError, UploadLimitError, @@ -14,338 +13,409 @@ import { } from '#/lib/media/video/errors' import {CompressedVideo} from '#/lib/media/video/types' import {logger} from '#/logger' -import {isWeb} from '#/platform/detection' -import {useCompressVideoMutation} from '#/state/queries/video/compress-video' -import {useVideoAgent} from '#/state/queries/video/util' -import {useUploadVideoMutation} from '#/state/queries/video/video-upload' - -type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' +import {createVideoAgent} from '#/state/queries/video/util' +import {uploadVideo} from '#/state/queries/video/video-upload' type Action = - | {type: 'SetStatus'; status: Status} - | {type: 'SetProgress'; progress: number} - | {type: 'SetError'; error: string | undefined} - | {type: 'Reset'} - | {type: 'SetAsset'; asset: ImagePickerAsset} - | {type: 'SetDimensions'; width: number; height: number} - | {type: 'SetVideo'; video: CompressedVideo} - | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} - | {type: 'SetComplete'; blobRef: BlobRef} + | {type: 'to_idle'; nextController: AbortController} + | { + type: 'idle_to_compressing' + asset: ImagePickerAsset + signal: AbortSignal + } + | { + type: 'compressing_to_uploading' + video: CompressedVideo + signal: AbortSignal + } + | { + type: 'uploading_to_processing' + jobId: string + signal: AbortSignal + } + | {type: 'to_error'; error: string; signal: AbortSignal} + | { + type: 'to_done' + blobRef: BlobRef + signal: AbortSignal + } + | {type: 'update_progress'; progress: number; signal: AbortSignal} + | { + type: 'update_dimensions' + width: number + height: number + signal: AbortSignal + } + | { + type: 'update_job_status' + jobStatus: AppBskyVideoDefs.JobStatus + signal: AbortSignal + } -export interface State { - status: Status - progress: number - asset?: ImagePickerAsset +type IdleState = { + status: 'idle' + progress: 0 + abortController: AbortController + asset?: undefined + video?: undefined + jobId?: undefined + pendingPublish?: undefined +} + +type ErrorState = { + status: 'error' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset | null video: CompressedVideo | null - jobStatus?: AppBskyVideoDefs.JobStatus - blobRef?: BlobRef - error?: string + jobId: string | null + error: string + pendingPublish?: undefined +} + +type CompressingState = { + status: 'compressing' + progress: number abortController: AbortController - pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} + asset: ImagePickerAsset + video?: undefined + jobId?: undefined + pendingPublish?: undefined } -export type VideoUploadDispatch = (action: Action) => void +type UploadingState = { + status: 'uploading' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId?: undefined + pendingPublish?: undefined +} -function reducer(queryClient: QueryClient) { - return (state: State, action: Action): State => { - let updatedState = state - if (action.type === 'SetStatus') { - updatedState = {...state, status: action.status} - } else if (action.type === 'SetProgress') { - updatedState = {...state, progress: action.progress} - } else if (action.type === 'SetError') { - updatedState = {...state, error: action.error} - } else if (action.type === 'Reset') { - state.abortController.abort() - queryClient.cancelQueries({ - queryKey: ['video'], - }) - updatedState = { - status: 'idle', - progress: 0, - video: null, - blobRef: undefined, - abortController: new AbortController(), - } - } else if (action.type === 'SetAsset') { - updatedState = { +type ProcessingState = { + status: 'processing' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId: string + jobStatus: AppBskyVideoDefs.JobStatus | null + pendingPublish?: undefined +} + +type DoneState = { + status: 'done' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId?: undefined + pendingPublish: {blobRef: BlobRef; mutableProcessed: boolean} +} + +export type State = + | IdleState + | ErrorState + | CompressingState + | UploadingState + | ProcessingState + | DoneState + +export function createVideoState( + abortController: AbortController = new AbortController(), +): IdleState { + return { + status: 'idle', + progress: 0, + abortController, + } +} + +export function videoReducer(state: State, action: Action): State { + if (action.type === 'to_idle') { + return createVideoState(action.nextController) + } + if (action.signal.aborted || action.signal !== state.abortController.signal) { + // This action is stale and the process that spawned it is no longer relevant. + return state + } + if (action.type === 'to_error') { + return { + status: 'error', + progress: 100, + abortController: state.abortController, + error: action.error, + asset: state.asset ?? null, + video: state.video ?? null, + jobId: state.jobId ?? null, + } + } else if (action.type === 'update_progress') { + if (state.status === 'compressing' || state.status === 'uploading') { + return { ...state, - asset: action.asset, + progress: action.progress, + } + } + } else if (action.type === 'idle_to_compressing') { + if (state.status === 'idle') { + return { status: 'compressing', - error: undefined, + progress: 0, + abortController: state.abortController, + asset: action.asset, } - } else if (action.type === 'SetDimensions') { - updatedState = { + } + } else if (action.type === 'update_dimensions') { + if (state.asset) { + return { ...state, - asset: state.asset - ? {...state.asset, width: action.width, height: action.height} - : undefined, + asset: {...state.asset, width: action.width, height: action.height}, } - } else if (action.type === 'SetVideo') { - updatedState = {...state, video: action.video, status: 'uploading'} - } else if (action.type === 'SetJobStatus') { - updatedState = {...state, jobStatus: action.jobStatus} - } else if (action.type === 'SetComplete') { - updatedState = { + } + } else if (action.type === 'compressing_to_uploading') { + if (state.status === 'compressing') { + return { + status: 'uploading', + progress: 0, + abortController: state.abortController, + asset: state.asset, + video: action.video, + } + } + return state + } else if (action.type === 'uploading_to_processing') { + if (state.status === 'uploading') { + return { + status: 'processing', + progress: 0, + abortController: state.abortController, + asset: state.asset, + video: state.video, + jobId: action.jobId, + jobStatus: null, + } + } + } else if (action.type === 'update_job_status') { + if (state.status === 'processing') { + return { ...state, + jobStatus: action.jobStatus, + progress: + action.jobStatus.progress !== undefined + ? action.jobStatus.progress / 100 + : state.progress, + } + } + } else if (action.type === 'to_done') { + if (state.status === 'processing') { + return { + status: 'done', + progress: 100, + abortController: state.abortController, + asset: state.asset, + video: state.video, pendingPublish: { blobRef: action.blobRef, mutableProcessed: false, }, - status: 'done', } } - return updatedState } + console.error( + 'Unexpected video action (' + + action.type + + ') while in ' + + state.status + + ' state', + ) + return state } -export function useUploadVideo({ - setStatus, - initialVideoUri, -}: { - setStatus: (status: string) => void - onSuccess: () => void - initialVideoUri?: string -}) { - const {_} = useLingui() - const queryClient = useQueryClient() - const [state, dispatch] = React.useReducer(reducer(queryClient), { - status: 'idle', - progress: 0, - video: null, - abortController: new AbortController(), - }) - - const {setJobId} = useUploadStatusQuery({ - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { - // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user - // Leaving it for now though - dispatch({ - type: 'SetJobStatus', - jobStatus: status, - }) - setStatus(status.state.toString()) - }, - onSuccess: blobRef => { - dispatch({ - type: 'SetComplete', - blobRef, - }) - }, - onError: useCallback( - error => { - logger.error('Error processing video', {safeMessage: error}) - dispatch({ - type: 'SetError', - error: _(msg`Video failed to process`), - }) - }, - [_], - ), - }) +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} - const {mutate: onVideoCompressed} = useUploadVideoMutation({ - onSuccess: response => { - dispatch({ - type: 'SetStatus', - status: 'processing', - }) - setJobId(response.jobId) - }, - onError: e => { - if (e instanceof AbortError) { - return - } else if (e instanceof ServerError || e instanceof UploadLimitError) { - let message - // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 - switch (e.message) { - case 'User is not allowed to upload videos': - message = _(msg`You are not allowed to upload videos.`) - break - case 'Uploading is disabled at the moment': - message = _( - msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, - ) - break - case "Failed to get user's upload stats": - message = _( - msg`We were unable to determine if you are allowed to upload videos. Please try again.`, - ) - break - case 'User has exceeded daily upload bytes limit': - message = _( - msg`You've reached your daily limit for video uploads (too many bytes)`, - ) - break - case 'User has exceeded daily upload videos limit': - message = _( - msg`You've reached your daily limit for video uploads (too many videos)`, - ) - break - case 'Account is not old enough to upload videos': - message = _( - msg`Your account is not yet old enough to upload videos. Please try again later.`, - ) - break - default: - message = e.message - break - } - dispatch({ - type: 'SetError', - error: message, - }) - } else { - dispatch({ - type: 'SetError', - error: _(msg`An error occurred while uploading the video.`), - }) - } - logger.error('Error uploading video', {safeMessage: e}) - }, - setProgress: p => { - dispatch({type: 'SetProgress', progress: p}) - }, - signal: state.abortController.signal, +export async function processVideo( + asset: ImagePickerAsset, + dispatch: (action: Action) => void, + agent: BskyAgent, + did: string, + signal: AbortSignal, + _: I18n['_'], +) { + dispatch({ + type: 'idle_to_compressing', + asset, + signal, }) - const {mutate: onSelectVideo} = useCompressVideoMutation({ - onProgress: p => { - dispatch({type: 'SetProgress', progress: p}) - }, - onSuccess: (video: CompressedVideo) => { + let video: CompressedVideo | undefined + try { + video = await compressVideo(asset, { + onProgress: num => { + dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) + }, + signal, + }) + } catch (e) { + const message = getCompressErrorMessage(e, _) + if (message !== null) { dispatch({ - type: 'SetVideo', - video, + type: 'to_error', + error: message, + signal, }) - onVideoCompressed(video) - }, - onError: e => { - if (e instanceof AbortError) { - return - } else if (e instanceof VideoTooLargeError) { - dispatch({ - type: 'SetError', - error: _(msg`The selected video is larger than 50MB.`), - }) - } else { - dispatch({ - type: 'SetError', - error: _(msg`An error occurred while compressing the video.`), - }) - logger.error('Error compressing video', {safeMessage: e}) - } - }, - signal: state.abortController.signal, + } + return + } + dispatch({ + type: 'compressing_to_uploading', + video, + signal, }) - const selectVideo = React.useCallback( - (asset: ImagePickerAsset) => { - // compression step on native converts to mp4, so no need to check there - if (isWeb) { - const mimeType = getMimeType(asset) - if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - throw new Error(_(msg`Unsupported video type: ${mimeType}`)) - } - } - + let uploadResponse: AppBskyVideoDefs.JobStatus | undefined + try { + uploadResponse = await uploadVideo({ + video, + agent, + did, + signal, + _, + setProgress: p => { + dispatch({type: 'update_progress', progress: p, signal}) + }, + }) + } catch (e) { + const message = getUploadErrorMessage(e, _) + if (message !== null) { dispatch({ - type: 'SetAsset', - asset, + type: 'to_error', + error: message, + signal, }) - onSelectVideo(asset) - }, - [_, onSelectVideo], - ) - - const clearVideo = () => { - dispatch({type: 'Reset'}) + } + return } - const updateVideoDimensions = useCallback((width: number, height: number) => { - dispatch({ - type: 'SetDimensions', - width, - height, - }) - }, []) + const jobId = uploadResponse.jobId + dispatch({ + type: 'uploading_to_processing', + jobId, + signal, + }) - // Whenever we receive an initial video uri, we should immediately run compression if necessary - useEffect(() => { - if (initialVideoUri) { - selectVideo({uri: initialVideoUri} as ImagePickerAsset) + let pollFailures = 0 + while (true) { + if (signal.aborted) { + return // Exit async loop } - }, [initialVideoUri, selectVideo]) - - return { - state, - dispatch, - selectVideo, - clearVideo, - updateVideoDimensions, - } -} -const useUploadStatusQuery = ({ - onStatusChange, - onSuccess, - onError, -}: { - onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void - onSuccess: (blobRef: BlobRef) => void - onError: (error: Error) => void -}) => { - const videoAgent = useVideoAgent() - const [enabled, setEnabled] = React.useState(true) - const [jobId, setJobId] = React.useState() + const videoAgent = createVideoAgent() + let status: JobStatus | undefined + let blob: BlobRef | undefined + try { + const response = await videoAgent.app.bsky.video.getJobStatus({jobId}) + status = response.data.jobStatus + pollFailures = 0 - const {error} = useQuery({ - queryKey: ['video', 'upload status', jobId], - queryFn: async () => { - if (!jobId) return // this won't happen, can ignore - - const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) - const status = data.jobStatus if (status.state === 'JOB_STATE_COMPLETED') { - setEnabled(false) - if (!status.blob) + blob = status.blob + if (!blob) { throw new Error('Job completed, but did not return a blob') - onSuccess(status.blob) + } } else if (status.state === 'JOB_STATE_FAILED') { throw new Error(status.error ?? 'Job failed to process') } - onStatusChange(status) - return status - }, - enabled: Boolean(jobId && enabled), - refetchInterval: 1500, - }) + } catch (e) { + if (!status) { + pollFailures++ + if (pollFailures < 50) { + await new Promise(resolve => setTimeout(resolve, 5000)) + continue // Continue async loop + } + } - useEffect(() => { - if (error) { - onError(error) - setEnabled(false) + logger.error('Error processing video', {safeMessage: e}) + dispatch({ + type: 'to_error', + error: _(msg`Video failed to process`), + signal, + }) + return // Exit async loop } - }, [error, onError]) - return { - setJobId: (_jobId: string) => { - setJobId(_jobId) - setEnabled(true) - }, + if (blob) { + dispatch({ + type: 'to_done', + blobRef: blob, + signal, + }) + } else { + dispatch({ + type: 'update_job_status', + jobStatus: status, + signal, + }) + } + + if ( + status.state !== 'JOB_STATE_COMPLETED' && + status.state !== 'JOB_STATE_FAILED' + ) { + await new Promise(resolve => setTimeout(resolve, 1500)) + continue // Continue async loop + } + + return // Exit async loop } } -function getMimeType(asset: ImagePickerAsset) { - if (isWeb) { - const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') - if (!mimeType) { - throw new Error('Could not determine mime type') - } - return mimeType +function getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null } - if (!asset.mimeType) { - throw new Error('Could not determine mime type') + if (e instanceof VideoTooLargeError) { + return _(msg`The selected video is larger than 50MB.`) + } + logger.error('Error compressing video', {safeMessage: e}) + return _(msg`An error occurred while compressing the video.`) +} + +function getUploadErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null + } + logger.error('Error uploading video', {safeMessage: e}) + if (e instanceof ServerError || e instanceof UploadLimitError) { + // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77 + switch (e.message) { + case 'User is not allowed to upload videos': + return _(msg`You are not allowed to upload videos.`) + case 'Uploading is disabled at the moment': + return _( + msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`, + ) + case "Failed to get user's upload stats": + return _( + msg`We were unable to determine if you are allowed to upload videos. Please try again.`, + ) + case 'User has exceeded daily upload bytes limit': + return _( + msg`You've reached your daily limit for video uploads (too many bytes)`, + ) + case 'User has exceeded daily upload videos limit': + return _( + msg`You've reached your daily limit for video uploads (too many videos)`, + ) + case 'Account is not old enough to upload videos': + return _( + msg`Your account is not yet old enough to upload videos. Please try again later.`, + ) + default: + return e.message + } } - return asset.mimeType + return _(msg`An error occurred while uploading the video.`) } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index f354f0f0dc..185a57fc35 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -36,6 +36,7 @@ import Animated, { ZoomOut, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, AppBskyFeedGetPostThread, @@ -82,9 +83,10 @@ import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' import { + createVideoState, + processVideo, State as VideoUploadState, - useUploadVideo, - VideoUploadDispatch, + videoReducer, } from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' @@ -147,7 +149,8 @@ export const ComposePost = ({ }) => { const {currentAccount} = useSession() const agent = useAgent() - const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) + const currentDid = currentAccount!.did + const {data: currentProfile} = useProfileQuery({did: currentDid}) const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const pal = usePalette('default') @@ -189,21 +192,50 @@ export const ComposePost = ({ const [videoAltText, setVideoAltText] = useState('') const [captions, setCaptions] = useState<{lang: string; file: File}[]>([]) - const { - selectVideo, - clearVideo, - state: videoUploadState, - updateVideoDimensions, - dispatch: videoUploadDispatch, - } = useUploadVideo({ - setStatus: setProcessingState, - onSuccess: () => { - if (publishOnUpload) { - onPressPublish(true) - } + const [videoUploadState, videoDispatch] = useReducer( + videoReducer, + undefined, + createVideoState, + ) + + const selectVideo = React.useCallback( + (asset: ImagePickerAsset) => { + processVideo( + asset, + videoDispatch, + agent, + currentDid, + videoUploadState.abortController.signal, + _, + ) }, - initialVideoUri: initVideoUri, - }) + [_, videoUploadState.abortController, videoDispatch, agent, currentDid], + ) + + // Whenever we receive an initial video uri, we should immediately run compression if necessary + useEffect(() => { + if (initVideoUri) { + selectVideo({uri: initVideoUri} as ImagePickerAsset) + } + }, [initVideoUri, selectVideo]) + + const clearVideo = React.useCallback(() => { + videoUploadState.abortController.abort() + videoDispatch({type: 'to_idle', nextController: new AbortController()}) + }, [videoUploadState.abortController, videoDispatch]) + + const updateVideoDimensions = useCallback( + (width: number, height: number) => { + videoDispatch({ + type: 'update_dimensions', + width, + height, + signal: videoUploadState.abortController.signal, + }) + }, + [videoUploadState.abortController], + ) + const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -400,19 +432,18 @@ export const ComposePost = ({ postgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), - video: videoUploadState.pendingPublish?.blobRef - ? { - blobRef: videoUploadState.pendingPublish.blobRef, - altText: videoAltText, - captions: captions, - aspectRatio: videoUploadState.asset - ? { - width: videoUploadState.asset?.width, - height: videoUploadState.asset?.height, - } - : undefined, - } - : undefined, + video: + videoUploadState.status === 'done' + ? { + blobRef: videoUploadState.pendingPublish.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: { + width: videoUploadState.asset.width, + height: videoUploadState.asset.height, + }, + } + : undefined, }) ).uri try { @@ -694,7 +725,7 @@ export const ComposePost = ({ error={error} videoUploadState={videoUploadState} clearError={() => setError('')} - videoUploadDispatch={videoUploadDispatch} + clearVideo={clearVideo} /> void - videoUploadDispatch: VideoUploadDispatch + clearVideo: () => void }) { const t = useTheme() const {_} = useLingui() const videoError = - videoUploadState.status !== 'idle' ? videoUploadState.error : undefined + videoUploadState.status === 'error' ? videoUploadState.error : undefined const error = standardError || videoError const onClearError = () => { if (standardError) { clearError() } else { - videoUploadDispatch({type: 'Reset'}) + clearVideo() } } @@ -1136,7 +1167,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobStatus?.jobId && ( + {videoError && videoUploadState.jobId && ( - Job ID: {videoUploadState.jobStatus.jobId} + Job ID: {videoUploadState.jobId} )} @@ -1174,9 +1205,7 @@ function ToolbarWrapper({ function VideoUploadToolbar({state}: {state: VideoUploadState}) { const t = useTheme() const {_} = useLingui() - const progress = state.jobStatus?.progress - ? state.jobStatus.progress / 100 - : state.progress + const progress = state.progress const shouldRotate = state.status === 'processing' && (progress === 0 || progress === 1) let wheelProgress = shouldRotate ? 0.33 : progress @@ -1212,16 +1241,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { case 'processing': text = _('Processing video...') break + case 'error': + text = _('Error') + wheelProgress = 100 + break case 'done': text = _('Video uploaded') break } - if (state.error) { - text = _('Error') - wheelProgress = 100 - } - return ( @@ -1229,7 +1257,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { size={30} borderWidth={1} borderColor={t.atoms.border_contrast_low.borderColor} - color={state.error ? t.palette.negative_500 : t.palette.primary_500} + color={ + state.status === 'error' + ? t.palette.negative_500 + : t.palette.primary_500 + } progress={wheelProgress} /> diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx index 2f2b4c3e7a..bbb3d95f2b 100644 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -9,12 +9,14 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' +import {BSKY_SERVICE} from '#/lib/constants' import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' +import {getHostnameFromUrl} from '#/lib/strings/url-helpers' +import {isWeb} from '#/platform/detection' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' -import {BSKY_SERVICE} from 'lib/constants' -import {getHostnameFromUrl} from 'lib/strings/url-helpers' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' @@ -58,16 +60,25 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { UIImagePickerPreferredAssetRepresentationMode.Current, }) if (response.assets && response.assets.length > 0) { - if (isNative) { - if (typeof response.assets[0].duration !== 'number') - throw Error('Asset is not a video') - if (response.assets[0].duration > VIDEO_MAX_DURATION) { - setError(_(msg`Videos must be less than 60 seconds long`)) - return - } - } + const asset = response.assets[0] try { - onSelectVideo(response.assets[0]) + if (isWeb) { + // compression step on native converts to mp4, so no need to check there + const mimeType = getMimeType(asset) + if ( + !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes) + ) { + throw Error(_(msg`Unsupported video type: ${mimeType}`)) + } + } else { + if (typeof asset.duration !== 'number') { + throw Error('Asset is not a video') + } + if (asset.duration > VIDEO_MAX_DURATION) { + throw Error(_(msg`Videos must be less than 60 seconds long`)) + } + } + onSelectVideo(asset) } catch (err) { if (err instanceof Error) { setError(err.message) @@ -132,3 +143,17 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) { /> ) } + +function getMimeType(asset: ImagePickerAsset) { + if (isWeb) { + const [mimeType] = asset.uri.slice('data:'.length).split(';base64,') + if (!mimeType) { + throw new Error('Could not determine mime type') + } + return mimeType + } + if (!asset.mimeType) { + throw new Error('Could not determine mime type') + } + return asset.mimeType +}