From 2aa365b5b6fed920d17d307252a7af7c52b95855 Mon Sep 17 00:00:00 2001 From: dan Date: Thu, 3 Oct 2024 14:57:48 +0900 Subject: Rename some files and variables (#5587) * Move composer reducers together * videoUploadState -> videoState * Inline videoDispatch --- src/lib/api/index.ts | 2 +- src/lib/media/video/compress.ts | 2 +- src/lib/media/video/upload.shared.ts | 61 ++++ src/lib/media/video/upload.ts | 79 +++++ src/lib/media/video/upload.web.ts | 95 ++++++ src/lib/media/video/util.ts | 53 ++++ src/state/queries/video/util.ts | 53 ---- src/state/queries/video/video-upload.shared.ts | 61 ---- src/state/queries/video/video-upload.ts | 79 ----- src/state/queries/video/video-upload.web.ts | 95 ------ src/state/queries/video/video.ts | 406 ------------------------- src/view/com/composer/Composer.tsx | 120 ++++---- src/view/com/composer/photos/Gallery.tsx | 2 +- src/view/com/composer/state.ts | 204 ------------- src/view/com/composer/state/composer.ts | 199 ++++++++++++ src/view/com/composer/state/video.ts | 406 +++++++++++++++++++++++++ 16 files changed, 950 insertions(+), 967 deletions(-) create mode 100644 src/lib/media/video/upload.shared.ts create mode 100644 src/lib/media/video/upload.ts create mode 100644 src/lib/media/video/upload.web.ts create mode 100644 src/lib/media/video/util.ts delete mode 100644 src/state/queries/video/util.ts delete mode 100644 src/state/queries/video/video-upload.shared.ts delete mode 100644 src/state/queries/video/video-upload.ts delete mode 100644 src/state/queries/video/video-upload.web.ts delete mode 100644 src/state/queries/video/video.ts delete mode 100644 src/view/com/composer/state.ts create mode 100644 src/view/com/composer/state/composer.ts create mode 100644 src/view/com/composer/state/video.ts (limited to 'src') diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 51bf51fff..8b7925004 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -24,7 +24,7 @@ import { threadgateAllowUISettingToAllowRecordValue, writeThreadgateRecord, } from '#/state/queries/threadgate' -import {ComposerState} from '#/view/com/composer/state' +import {ComposerState} from '#/view/com/composer/state/composer' import {LinkMeta} from '../link-meta/link-meta' import {uploadBlob} from './upload-blob' diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dec9032a3..c2d1470c6 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' import {ImagePickerAsset} from 'expo-image-picker' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {extToMime} from '#/state/queries/video/util' import {CompressedVideo} from './types' +import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts new file mode 100644 index 000000000..8c217eadc --- /dev/null +++ b/src/lib/media/video/upload.shared.ts @@ -0,0 +1,61 @@ +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' + +import {VIDEO_SERVICE_DID} from '#/lib/constants' +import {UploadLimitError} from '#/lib/media/video/errors' +import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' +import {createVideoAgent} from './util' + +export async function getServiceAuthToken({ + agent, + aud, + lxm, + exp, +}: { + agent: BskyAgent + aud?: string + lxm: string + exp?: number +}) { + 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 async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { + const token = await getServiceAuthToken({ + agent, + lxm: 'app.bsky.video.getUploadLimits', + aud: VIDEO_SERVICE_DID, + }) + 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 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.`, + ), + ) + } + } +} diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts new file mode 100644 index 000000000..3330370b3 --- /dev/null +++ b/src/lib/media/video/upload.ts @@ -0,0 +1,79 @@ +import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' +import {nanoid} from 'nanoid/non-secure' + +import {AbortError} from '#/lib/async/cancelable' +import {ServerError} from '#/lib/media/video/errors' +import {CompressedVideo} from '#/lib/media/video/types' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' + +export async function uploadVideo({ + video, + agent, + did, + setProgress, + signal, + _, +}: { + video: CompressedVideo + agent: BskyAgent + did: string + setProgress: (progress: number) => void + signal: AbortSignal + _: 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 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), + ) + + if (signal.aborted) { + throw new AbortError() + } + const res = await uploadTask.uploadAsync() + + if (!res?.body) { + throw new Error('No response') + } + + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus + + if (!responseBody.jobId) { + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return responseBody +} diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts new file mode 100644 index 000000000..ec65f96c9 --- /dev/null +++ b/src/lib/media/video/upload.web.ts @@ -0,0 +1,95 @@ +import {AppBskyVideoDefs} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' +import {nanoid} from 'nanoid/non-secure' + +import {AbortError} from '#/lib/async/cancelable' +import {ServerError} from '#/lib/media/video/errors' +import {CompressedVideo} from '#/lib/media/video/types' +import {createVideoEndpointUrl, mimeToExt} from './util' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' + +export async function uploadVideo({ + video, + agent, + did, + setProgress, + signal, + _, +}: { + video: CompressedVideo + agent: BskyAgent + did: string + setProgress: (progress: number) => void + signal: AbortSignal + _: 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 + }) + + 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) + }) + 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`))) + } + } + 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`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return res +} diff --git a/src/lib/media/video/util.ts b/src/lib/media/video/util.ts new file mode 100644 index 000000000..87b422c2c --- /dev/null +++ b/src/lib/media/video/util.ts @@ -0,0 +1,53 @@ +import {AtpAgent} from '@atproto/api' + +import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' + +export const createVideoEndpointUrl = ( + route: string, + params?: Record, +) => { + const url = new URL(VIDEO_SERVICE) + url.pathname = route + if (params) { + for (const key in params) { + url.searchParams.set(key, params[key]) + } + } + return url.href +} + +export function createVideoAgent() { + return new AtpAgent({ + service: VIDEO_SERVICE, + }) +} + +export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { + switch (mimeType) { + case 'video/mp4': + return 'mp4' + case 'video/webm': + return 'webm' + case 'video/mpeg': + return 'mpeg' + case 'video/quicktime': + return 'mov' + default: + throw new Error(`Unsupported mime type: ${mimeType}`) + } +} + +export function extToMime(ext: string) { + switch (ext) { + case 'mp4': + return 'video/mp4' + case 'webm': + return 'video/webm' + case 'mpeg': + return 'video/mpeg' + case 'mov': + return 'video/quicktime' + default: + throw new Error(`Unsupported file extension: ${ext}`) + } +} diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts deleted file mode 100644 index 87b422c2c..000000000 --- a/src/state/queries/video/util.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {AtpAgent} from '@atproto/api' - -import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' - -export const createVideoEndpointUrl = ( - route: string, - params?: Record, -) => { - const url = new URL(VIDEO_SERVICE) - url.pathname = route - if (params) { - for (const key in params) { - url.searchParams.set(key, params[key]) - } - } - return url.href -} - -export function createVideoAgent() { - return new AtpAgent({ - service: VIDEO_SERVICE, - }) -} - -export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { - switch (mimeType) { - case 'video/mp4': - return 'mp4' - case 'video/webm': - return 'webm' - case 'video/mpeg': - return 'mpeg' - case 'video/quicktime': - return 'mov' - default: - throw new Error(`Unsupported mime type: ${mimeType}`) - } -} - -export function extToMime(ext: string) { - switch (ext) { - case 'mp4': - return 'video/mp4' - case 'webm': - return 'video/webm' - case 'mpeg': - return 'video/mpeg' - case 'mov': - return 'video/quicktime' - default: - throw new Error(`Unsupported file extension: ${ext}`) - } -} diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts deleted file mode 100644 index 8c217eadc..000000000 --- a/src/state/queries/video/video-upload.shared.ts +++ /dev/null @@ -1,61 +0,0 @@ -import {BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' -import {msg} from '@lingui/macro' - -import {VIDEO_SERVICE_DID} from '#/lib/constants' -import {UploadLimitError} from '#/lib/media/video/errors' -import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' -import {createVideoAgent} from './util' - -export async function getServiceAuthToken({ - agent, - aud, - lxm, - exp, -}: { - agent: BskyAgent - aud?: string - lxm: string - exp?: number -}) { - 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 async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { - const token = await getServiceAuthToken({ - agent, - lxm: 'app.bsky.video.getUploadLimits', - aud: VIDEO_SERVICE_DID, - }) - 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 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.`, - ), - ) - } - } -} diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts deleted file mode 100644 index 46f24a58b..000000000 --- a/src/state/queries/video/video-upload.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {createUploadTask, FileSystemUploadType} from 'expo-file-system' -import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' -import {msg} from '@lingui/macro' -import {nanoid} from 'nanoid/non-secure' - -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 {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' - -export async function uploadVideo({ - video, - agent, - did, - setProgress, - signal, - _, -}: { - video: CompressedVideo - agent: BskyAgent - did: string - setProgress: (progress: number) => void - signal: AbortSignal - _: 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 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), - ) - - if (signal.aborted) { - throw new AbortError() - } - const res = await uploadTask.uploadAsync() - - if (!res?.body) { - throw new Error('No response') - } - - const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus - - if (!responseBody.jobId) { - throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) - } - - 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 deleted file mode 100644 index bbae64199..000000000 --- a/src/state/queries/video/video-upload.web.ts +++ /dev/null @@ -1,95 +0,0 @@ -import {AppBskyVideoDefs} from '@atproto/api' -import {BskyAgent} from '@atproto/api' -import {I18n} from '@lingui/core' -import {msg} from '@lingui/macro' -import {nanoid} from 'nanoid/non-secure' - -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 {getServiceAuthToken, getVideoUploadLimits} from './video-upload.shared' - -export async function uploadVideo({ - video, - agent, - did, - setProgress, - signal, - _, -}: { - video: CompressedVideo - agent: BskyAgent - did: string - setProgress: (progress: number) => void - signal: AbortSignal - _: 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 - }) - - 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) - }) - 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`))) - } - } - 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`)) - } - - if (signal.aborted) { - throw new AbortError() - } - return res -} diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts deleted file mode 100644 index dbbb6c202..000000000 --- a/src/state/queries/video/video.ts +++ /dev/null @@ -1,406 +0,0 @@ -import {ImagePickerAsset} from 'expo-image-picker' -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 {AbortError} from '#/lib/async/cancelable' -import {compressVideo} from '#/lib/media/video/compress' -import { - ServerError, - UploadLimitError, - VideoTooLargeError, -} from '#/lib/media/video/errors' -import {CompressedVideo} from '#/lib/media/video/types' -import {logger} from '#/logger' -import {createVideoAgent} from '#/state/queries/video/util' -import {uploadVideo} from '#/state/queries/video/video-upload' - -export type VideoAction = - | { - 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 - } - -const noopController = new AbortController() -noopController.abort() - -export const NO_VIDEO = Object.freeze({ - status: 'idle', - progress: 0, - abortController: noopController, - asset: undefined, - video: undefined, - jobId: undefined, - pendingPublish: undefined, -}) - -export type NoVideoState = typeof NO_VIDEO - -type ErrorState = { - status: 'error' - progress: 100 - abortController: AbortController - asset: ImagePickerAsset | null - video: CompressedVideo | null - jobId: string | null - error: string - pendingPublish?: undefined -} - -type CompressingState = { - status: 'compressing' - progress: number - abortController: AbortController - asset: ImagePickerAsset - video?: undefined - jobId?: undefined - pendingPublish?: undefined -} - -type UploadingState = { - status: 'uploading' - progress: number - abortController: AbortController - asset: ImagePickerAsset - video: CompressedVideo - jobId?: undefined - pendingPublish?: undefined -} - -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 VideoState = - | ErrorState - | CompressingState - | UploadingState - | ProcessingState - | DoneState - -export function createVideoState( - asset: ImagePickerAsset, - abortController: AbortController, -): CompressingState { - return { - status: 'compressing', - progress: 0, - abortController, - asset, - } -} - -export function videoReducer( - state: VideoState, - action: VideoAction, -): VideoState { - 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, - progress: action.progress, - } - } - } else if (action.type === 'update_dimensions') { - if (state.asset) { - return { - ...state, - asset: {...state.asset, width: action.width, height: action.height}, - } - } - } 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, - }, - } - } - } - console.error( - 'Unexpected video action (' + - action.type + - ') while in ' + - state.status + - ' state', - ) - return state -} - -function trunc2dp(num: number) { - return Math.trunc(num * 100) / 100 -} - -export async function processVideo( - asset: ImagePickerAsset, - dispatch: (action: VideoAction) => void, - agent: BskyAgent, - did: string, - signal: AbortSignal, - _: I18n['_'], -) { - 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: 'to_error', - error: message, - signal, - }) - } - return - } - dispatch({ - type: 'compressing_to_uploading', - video, - signal, - }) - - 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: 'to_error', - error: message, - signal, - }) - } - return - } - - const jobId = uploadResponse.jobId - dispatch({ - type: 'uploading_to_processing', - jobId, - signal, - }) - - let pollFailures = 0 - while (true) { - if (signal.aborted) { - return // Exit async loop - } - - 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 - - if (status.state === 'JOB_STATE_COMPLETED') { - blob = status.blob - if (!blob) { - throw new Error('Job completed, but did not return a blob') - } - } else if (status.state === 'JOB_STATE_FAILED') { - throw new Error(status.error ?? 'Job failed to process') - } - } catch (e) { - if (!status) { - pollFailures++ - if (pollFailures < 50) { - await new Promise(resolve => setTimeout(resolve, 5000)) - continue // Continue async loop - } - } - - logger.error('Error processing video', {safeMessage: e}) - dispatch({ - type: 'to_error', - error: _(msg`Video failed to process`), - signal, - }) - return // Exit async loop - } - - 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 getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { - if (e instanceof AbortError) { - return null - } - 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 _(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 59aae2951..f4e290ca8 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -82,13 +82,6 @@ import {useProfileQuery} from '#/state/queries/profile' import {Gif} from '#/state/queries/tenor' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util' -import {NO_VIDEO, NoVideoState} from '#/state/queries/video/video' -import { - processVideo, - VideoAction, - VideoState, - VideoState as VideoUploadState, -} from '#/state/queries/video/video' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {ComposerOpts} from '#/state/shell/composer' @@ -123,7 +116,8 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' -import {composerReducer, createComposerState} from './state' +import {composerReducer, createComposerState} from './state/composer' +import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' const MAX_IMAGES = 4 @@ -200,16 +194,10 @@ export const ComposePost = ({ createComposerState, ) - let videoUploadState: VideoState | NoVideoState = NO_VIDEO + let videoState: VideoState | NoVideoState = NO_VIDEO if (composerState.embed.media?.type === 'video') { - videoUploadState = composerState.embed.media.video + videoState = composerState.embed.media.video } - const videoDispatch = useCallback( - (videoAction: VideoAction) => { - dispatch({type: 'embed_update_video', videoAction}) - }, - [dispatch], - ) const selectVideo = React.useCallback( (asset: ImagePickerAsset) => { @@ -217,14 +205,14 @@ export const ComposePost = ({ dispatch({type: 'embed_add_video', asset, abortController}) processVideo( asset, - videoDispatch, + videoAction => dispatch({type: 'embed_update_video', videoAction}), agent, currentDid, abortController.signal, _, ) }, - [_, videoDispatch, agent, currentDid], + [_, agent, currentDid], ) // Whenever we receive an initial video uri, we should immediately run compression if necessary @@ -235,23 +223,26 @@ export const ComposePost = ({ }, [initVideoUri, selectVideo]) const clearVideo = React.useCallback(() => { - videoUploadState.abortController.abort() + videoState.abortController.abort() dispatch({type: 'embed_remove_video'}) - }, [videoUploadState.abortController, dispatch]) + }, [videoState.abortController, dispatch]) const updateVideoDimensions = useCallback( (width: number, height: number) => { - videoDispatch({ - type: 'update_dimensions', - width, - height, - signal: videoUploadState.abortController.signal, + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_dimensions', + width, + height, + signal: videoState.abortController.signal, + }, }) }, - [videoUploadState.abortController, videoDispatch], + [videoState.abortController], ) - const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video) + const hasVideo = Boolean(videoState.asset || videoState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -288,7 +279,7 @@ export const ComposePost = ({ graphemeLength > 0 || images.length !== 0 || extGif || - videoUploadState.status !== 'idle' + videoState.status !== 'idle' ) { closeAllDialogs() Keyboard.dismiss() @@ -303,7 +294,7 @@ export const ComposePost = ({ closeAllDialogs, discardPromptControl, onClose, - videoUploadState.status, + videoState.status, ]) useImperativeHandle(cancelRef, () => ({onPressCancel})) @@ -400,8 +391,8 @@ export const ComposePost = ({ if ( !finishedUploading && - videoUploadState.asset && - videoUploadState.status !== 'done' + videoState.asset && + videoState.status !== 'done' ) { setPublishOnUpload(true) return @@ -414,7 +405,7 @@ export const ComposePost = ({ images.length === 0 && !extLink && !quote && - videoUploadState.status === 'idle' + videoState.status === 'idle' ) { setError(_(msg`Did you want to say anything?`)) return @@ -442,14 +433,14 @@ export const ComposePost = ({ onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), video: - videoUploadState.status === 'done' + videoState.status === 'done' ? { - blobRef: videoUploadState.pendingPublish.blobRef, + blobRef: videoState.pendingPublish.blobRef, altText: videoAltText, captions: captions, aspectRatio: { - width: videoUploadState.asset.width, - height: videoUploadState.asset.height, + width: videoState.asset.width, + height: videoState.asset.height, }, } : undefined, @@ -550,20 +541,20 @@ export const ComposePost = ({ setLangPrefs, threadgateAllowUISettings, videoAltText, - videoUploadState.asset, - videoUploadState.pendingPublish, - videoUploadState.status, + videoState.asset, + videoState.pendingPublish, + videoState.status, ], ) React.useEffect(() => { - if (videoUploadState.pendingPublish && publishOnUpload) { - if (!videoUploadState.pendingPublish.mutableProcessed) { - videoUploadState.pendingPublish.mutableProcessed = true + if (videoState.pendingPublish && publishOnUpload) { + if (!videoState.pendingPublish.mutableProcessed) { + videoState.pendingPublish.mutableProcessed = true onPressPublish(true) } } - }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish]) + }, [onPressPublish, publishOnUpload, videoState.pendingPublish]) const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, @@ -576,10 +567,10 @@ export const ComposePost = ({ const canSelectImages = images.length < MAX_IMAGES && !extLink && - videoUploadState.status === 'idle' && - !videoUploadState.video + videoState.status === 'idle' && + !videoState.video const hasMedia = - images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) + images.length > 0 || Boolean(extLink) || Boolean(videoState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) @@ -694,9 +685,7 @@ export const ComposePost = ({ size="small" style={[a.rounded_full, a.py_sm]} onPress={() => onPressPublish()} - disabled={ - videoUploadState.status !== 'idle' && publishOnUpload - }> + disabled={videoState.status !== 'idle' && publishOnUpload}> {replyTo ? ( Reply @@ -732,7 +721,7 @@ export const ComposePost = ({ )} setError('')} clearVideo={clearVideo} /> @@ -798,17 +787,17 @@ export const ComposePost = ({ style={[a.w_full, a.mt_lg]} entering={native(ZoomIn)} exiting={native(ZoomOut)}> - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( + {videoState.asset && + (videoState.status === 'compressing' ? ( - ) : videoUploadState.video ? ( + ) : videoState.video ? ( @@ -854,9 +843,8 @@ export const ComposePost = ({ t.atoms.border_contrast_medium, styles.bottomBar, ]}> - {videoUploadState.status !== 'idle' && - videoUploadState.status !== 'done' ? ( - + {videoState.status !== 'idle' && videoState.status !== 'done' ? ( + ) : ( void clearVideo: () => void }) { @@ -1134,7 +1122,7 @@ function ErrorBanner({ const {_} = useLingui() const videoError = - videoUploadState.status === 'error' ? videoUploadState.error : undefined + videoState.status === 'error' ? videoState.error : undefined const error = standardError || videoError const onClearError = () => { @@ -1176,7 +1164,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobId && ( + {videoError && videoState.jobId && ( - Job ID: {videoUploadState.jobId} + Job ID: {videoState.jobId} )} @@ -1211,7 +1199,7 @@ function ToolbarWrapper({ ) } -function VideoUploadToolbar({state}: {state: VideoUploadState}) { +function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {_} = useLingui() const progress = state.progress diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 5692f3d2c..5ff7042bc 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,7 +21,7 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' -import {ComposerAction} from '../state' +import {ComposerAction} from '../state/composer' import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' diff --git a/src/view/com/composer/state.ts b/src/view/com/composer/state.ts deleted file mode 100644 index 8e974ad7a..000000000 --- a/src/view/com/composer/state.ts +++ /dev/null @@ -1,204 +0,0 @@ -import {ImagePickerAsset} from 'expo-image-picker' - -import {ComposerImage, createInitialImages} from '#/state/gallery' -import { - createVideoState, - VideoAction, - videoReducer, - VideoState, -} from '#/state/queries/video/video' -import {ComposerOpts} from '#/state/shell/composer' - -type PostRecord = { - uri: string -} - -type ImagesMedia = { - type: 'images' - images: ComposerImage[] - labels: string[] -} - -type VideoMedia = { - type: 'video' - video: VideoState -} - -type ComposerEmbed = { - // TODO: Other record types. - record: PostRecord | undefined - // TODO: Other media types. - media: ImagesMedia | VideoMedia | undefined -} - -export type ComposerState = { - // TODO: Other draft data. - embed: ComposerEmbed -} - -export type ComposerAction = - | {type: 'embed_add_images'; images: ComposerImage[]} - | {type: 'embed_update_image'; image: ComposerImage} - | {type: 'embed_remove_image'; image: ComposerImage} - | { - type: 'embed_add_video' - asset: ImagePickerAsset - abortController: AbortController - } - | {type: 'embed_remove_video'} - | {type: 'embed_update_video'; videoAction: VideoAction} - -const MAX_IMAGES = 4 - -export function composerReducer( - state: ComposerState, - action: ComposerAction, -): ComposerState { - switch (action.type) { - case 'embed_add_images': { - if (action.images.length === 0) { - return state - } - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (!prevMedia) { - nextMedia = { - type: 'images', - images: action.images.slice(0, MAX_IMAGES), - labels: [], - } - } else if (prevMedia.type === 'images') { - nextMedia = { - ...prevMedia, - images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), - } - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - case 'embed_update_image': { - const prevMedia = state.embed.media - if (prevMedia?.type === 'images') { - const updatedImage = action.image - const nextMedia = { - ...prevMedia, - images: prevMedia.images.map(img => { - if (img.source.id === updatedImage.source.id) { - return updatedImage - } - return img - }), - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - return state - } - case 'embed_remove_image': { - const prevMedia = state.embed.media - if (prevMedia?.type === 'images') { - const removedImage = action.image - let nextMedia: ImagesMedia | undefined = { - ...prevMedia, - images: prevMedia.images.filter(img => { - return img.source.id !== removedImage.source.id - }), - } - if (nextMedia.images.length === 0) { - nextMedia = undefined - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - return state - } - case 'embed_add_video': { - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (!prevMedia) { - nextMedia = { - type: 'video', - video: createVideoState(action.asset, action.abortController), - } - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - case 'embed_update_video': { - const videoAction = action.videoAction - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (prevMedia?.type === 'video') { - nextMedia = { - ...prevMedia, - video: videoReducer(prevMedia.video, videoAction), - } - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - case 'embed_remove_video': { - const prevMedia = state.embed.media - let nextMedia = prevMedia - if (prevMedia?.type === 'video') { - nextMedia = undefined - } - return { - ...state, - embed: { - ...state.embed, - media: nextMedia, - }, - } - } - default: - return state - } -} - -export function createComposerState({ - initImageUris, -}: { - initImageUris: ComposerOpts['imageUris'] -}): ComposerState { - let media: ImagesMedia | undefined - if (initImageUris?.length) { - media = { - type: 'images', - images: createInitialImages(initImageUris), - labels: [], - } - } - // TODO: initial video. - return { - embed: { - record: undefined, - media, - }, - } -} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts new file mode 100644 index 000000000..a23a5d8c8 --- /dev/null +++ b/src/view/com/composer/state/composer.ts @@ -0,0 +1,199 @@ +import {ImagePickerAsset} from 'expo-image-picker' + +import {ComposerImage, createInitialImages} from '#/state/gallery' +import {ComposerOpts} from '#/state/shell/composer' +import {createVideoState, VideoAction, videoReducer, VideoState} from './video' + +type PostRecord = { + uri: string +} + +type ImagesMedia = { + type: 'images' + images: ComposerImage[] + labels: string[] +} + +type VideoMedia = { + type: 'video' + video: VideoState +} + +type ComposerEmbed = { + // TODO: Other record types. + record: PostRecord | undefined + // TODO: Other media types. + media: ImagesMedia | VideoMedia | undefined +} + +export type ComposerState = { + // TODO: Other draft data. + embed: ComposerEmbed +} + +export type ComposerAction = + | {type: 'embed_add_images'; images: ComposerImage[]} + | {type: 'embed_update_image'; image: ComposerImage} + | {type: 'embed_remove_image'; image: ComposerImage} + | { + type: 'embed_add_video' + asset: ImagePickerAsset + abortController: AbortController + } + | {type: 'embed_remove_video'} + | {type: 'embed_update_video'; videoAction: VideoAction} + +const MAX_IMAGES = 4 + +export function composerReducer( + state: ComposerState, + action: ComposerAction, +): ComposerState { + switch (action.type) { + case 'embed_add_images': { + if (action.images.length === 0) { + return state + } + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'images', + images: action.images.slice(0, MAX_IMAGES), + labels: [], + } + } else if (prevMedia.type === 'images') { + nextMedia = { + ...prevMedia, + images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_image': { + const prevMedia = state.embed.media + if (prevMedia?.type === 'images') { + const updatedImage = action.image + const nextMedia = { + ...prevMedia, + images: prevMedia.images.map(img => { + if (img.source.id === updatedImage.source.id) { + return updatedImage + } + return img + }), + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + return state + } + case 'embed_remove_image': { + const prevMedia = state.embed.media + if (prevMedia?.type === 'images') { + const removedImage = action.image + let nextMedia: ImagesMedia | undefined = { + ...prevMedia, + images: prevMedia.images.filter(img => { + return img.source.id !== removedImage.source.id + }), + } + if (nextMedia.images.length === 0) { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + return state + } + case 'embed_add_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (!prevMedia) { + nextMedia = { + type: 'video', + video: createVideoState(action.asset, action.abortController), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_update_video': { + const videoAction = action.videoAction + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = { + ...prevMedia, + video: videoReducer(prevMedia.video, videoAction), + } + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + case 'embed_remove_video': { + const prevMedia = state.embed.media + let nextMedia = prevMedia + if (prevMedia?.type === 'video') { + nextMedia = undefined + } + return { + ...state, + embed: { + ...state.embed, + media: nextMedia, + }, + } + } + default: + return state + } +} + +export function createComposerState({ + initImageUris, +}: { + initImageUris: ComposerOpts['imageUris'] +}): ComposerState { + let media: ImagesMedia | undefined + if (initImageUris?.length) { + media = { + type: 'images', + images: createInitialImages(initImageUris), + labels: [], + } + } + // TODO: initial video. + return { + embed: { + record: undefined, + media, + }, + } +} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts new file mode 100644 index 000000000..269505657 --- /dev/null +++ b/src/view/com/composer/state/video.ts @@ -0,0 +1,406 @@ +import {ImagePickerAsset} from 'expo-image-picker' +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 {createVideoAgent} from '#/lib/media/video/util' +import {uploadVideo} from '#/lib/media/video/upload' +import {AbortError} from '#/lib/async/cancelable' +import {compressVideo} from '#/lib/media/video/compress' +import { + ServerError, + UploadLimitError, + VideoTooLargeError, +} from '#/lib/media/video/errors' +import {CompressedVideo} from '#/lib/media/video/types' +import {logger} from '#/logger' + +export type VideoAction = + | { + 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 + } + +const noopController = new AbortController() +noopController.abort() + +export const NO_VIDEO = Object.freeze({ + status: 'idle', + progress: 0, + abortController: noopController, + asset: undefined, + video: undefined, + jobId: undefined, + pendingPublish: undefined, +}) + +export type NoVideoState = typeof NO_VIDEO + +type ErrorState = { + status: 'error' + progress: 100 + abortController: AbortController + asset: ImagePickerAsset | null + video: CompressedVideo | null + jobId: string | null + error: string + pendingPublish?: undefined +} + +type CompressingState = { + status: 'compressing' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video?: undefined + jobId?: undefined + pendingPublish?: undefined +} + +type UploadingState = { + status: 'uploading' + progress: number + abortController: AbortController + asset: ImagePickerAsset + video: CompressedVideo + jobId?: undefined + pendingPublish?: undefined +} + +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 VideoState = + | ErrorState + | CompressingState + | UploadingState + | ProcessingState + | DoneState + +export function createVideoState( + asset: ImagePickerAsset, + abortController: AbortController, +): CompressingState { + return { + status: 'compressing', + progress: 0, + abortController, + asset, + } +} + +export function videoReducer( + state: VideoState, + action: VideoAction, +): VideoState { + 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, + progress: action.progress, + } + } + } else if (action.type === 'update_dimensions') { + if (state.asset) { + return { + ...state, + asset: {...state.asset, width: action.width, height: action.height}, + } + } + } 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, + }, + } + } + } + console.error( + 'Unexpected video action (' + + action.type + + ') while in ' + + state.status + + ' state', + ) + return state +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} + +export async function processVideo( + asset: ImagePickerAsset, + dispatch: (action: VideoAction) => void, + agent: BskyAgent, + did: string, + signal: AbortSignal, + _: I18n['_'], +) { + 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: 'to_error', + error: message, + signal, + }) + } + return + } + dispatch({ + type: 'compressing_to_uploading', + video, + signal, + }) + + 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: 'to_error', + error: message, + signal, + }) + } + return + } + + const jobId = uploadResponse.jobId + dispatch({ + type: 'uploading_to_processing', + jobId, + signal, + }) + + let pollFailures = 0 + while (true) { + if (signal.aborted) { + return // Exit async loop + } + + 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 + + if (status.state === 'JOB_STATE_COMPLETED') { + blob = status.blob + if (!blob) { + throw new Error('Job completed, but did not return a blob') + } + } else if (status.state === 'JOB_STATE_FAILED') { + throw new Error(status.error ?? 'Job failed to process') + } + } catch (e) { + if (!status) { + pollFailures++ + if (pollFailures < 50) { + await new Promise(resolve => setTimeout(resolve, 5000)) + continue // Continue async loop + } + } + + logger.error('Error processing video', {safeMessage: e}) + dispatch({ + type: 'to_error', + error: _(msg`Video failed to process`), + signal, + }) + return // Exit async loop + } + + 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 getCompressErrorMessage(e: unknown, _: I18n['_']): string | null { + if (e instanceof AbortError) { + return null + } + 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 _(msg`An error occurred while uploading the video.`) +} -- cgit 1.4.1