about summary refs log tree commit diff
path: root/src/view/com/composer/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer/state')
-rw-r--r--src/view/com/composer/state/composer.ts199
-rw-r--r--src/view/com/composer/state/video.ts406
2 files changed, 605 insertions, 0 deletions
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.`)
+}