about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/lib/constants.ts3
-rw-r--r--src/lib/media/video/errors.ts7
-rw-r--r--src/state/queries/video/util.ts8
-rw-r--r--src/state/queries/video/video-upload.shared.ts73
-rw-r--r--src/state/queries/video/video-upload.ts28
-rw-r--r--src/state/queries/video/video-upload.web.ts31
-rw-r--r--src/state/queries/video/video.ts40
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx2
8 files changed, 146 insertions, 46 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 5be099d0e..9bf1fb35e 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
 
 export const MAX_LABELERS = 20
 
+export const VIDEO_SERVICE = 'https://video.bsky.app'
+export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
+
 export const SUPPORTED_MIME_TYPES = [
   'video/mp4',
   'video/mpeg',
diff --git a/src/lib/media/video/errors.ts b/src/lib/media/video/errors.ts
index a06a239e1..1c55a9ee9 100644
--- a/src/lib/media/video/errors.ts
+++ b/src/lib/media/video/errors.ts
@@ -11,3 +11,10 @@ export class ServerError extends Error {
     this.name = 'ServerError'
   }
 }
+
+export class UploadLimitError extends Error {
+  constructor(message: string) {
+    super(message)
+    this.name = 'UploadLimitError'
+  }
+}
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
index e019848a1..7ea38d8dc 100644
--- a/src/state/queries/video/util.ts
+++ b/src/state/queries/video/util.ts
@@ -1,15 +1,13 @@
 import {useMemo} from 'react'
 import {AtpAgent} from '@atproto/api'
 
-import {SupportedMimeTypes} from '#/lib/constants'
-
-const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
+import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
 
 export const createVideoEndpointUrl = (
   route: string,
   params?: Record<string, string>,
 ) => {
-  const url = new URL(`${UPLOAD_ENDPOINT}`)
+  const url = new URL(VIDEO_SERVICE)
   url.pathname = route
   if (params) {
     for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
 export function useVideoAgent() {
   return useMemo(() => {
     return new AtpAgent({
-      service: UPLOAD_ENDPOINT,
+      service: VIDEO_SERVICE,
     })
   }, [])
 }
diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts
new file mode 100644
index 000000000..6b633bf21
--- /dev/null
+++ b/src/state/queries/video/video-upload.shared.ts
@@ -0,0 +1,73 @@
+import {useCallback} from 'react'
+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'
+
+export function useServiceAuthToken({
+  aud,
+  lxm,
+  exp,
+}: {
+  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])
+}
+
+export function useVideoUploadLimits() {
+  const agent = useVideoAgent()
+  const getToken = useServiceAuthToken({
+    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)
+      } 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 23e04316e..170b53890 100644
--- a/src/state/queries/video/video-upload.ts
+++ b/src/state/queries/video/video-upload.ts
@@ -9,8 +9,8 @@ import {cancelable} 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 {useAgent, useSession} from '#/state/session'
-import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
 
 export const useUploadVideoMutation = ({
   onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
   signal: AbortSignal
 }) => {
   const {currentAccount} = useSession()
-  const agent = useAgent()
+  const getToken = useServiceAuthToken({
+    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)}`,
       })
 
-      const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
-
-      if (!serviceAuthAud) {
-        throw new Error('Agent does not have a PDS URL')
-      }
-
-      const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
-        {
-          aud: serviceAuthAud,
-          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 ${serviceAuth.token}`,
+            Authorization: `Bearer ${await getToken()}`,
           },
           httpMethod: 'POST',
           uploadType: FileSystemUploadType.BINARY_CONTENT,
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
index 40f586450..c93e20603 100644
--- a/src/state/queries/video/video-upload.web.ts
+++ b/src/state/queries/video/video-upload.web.ts
@@ -8,8 +8,8 @@ import {cancelable} 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 {useAgent, useSession} from '#/state/session'
-import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
 
 export const useUploadVideoMutation = ({
   onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
   signal: AbortSignal
 }) => {
   const {currentAccount} = useSession()
-  const agent = useAgent()
+  const getToken = useServiceAuthToken({
+    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)}`,
       })
 
-      const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
-
-      if (!serviceAuthAud) {
-        throw new Error('Agent does not have a PDS URL')
-      }
-
-      const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
-        {
-          aud: serviceAuthAud,
-          lxm: 'com.atproto.repo.uploadBlob',
-          exp: Date.now() / 1000 + 60 * 30, // 30 minutes
-        },
-      )
-
       let bytes = video.bytes
-
       if (!bytes) {
         bytes = await fetch(video.uri).then(res => res.arrayBuffer())
       }
 
+      const token = await getToken()
+
       const xhr = new XMLHttpRequest()
       const res = await new Promise<AppBskyVideoDefs.JobStatus>(
         (resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
           }
           xhr.open('POST', uri)
           xhr.setRequestHeader('Content-Type', video.mimeType)
-          xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
+          xhr.setRequestHeader('Authorization', `Bearer ${token}`)
           xhr.send(bytes)
         },
       )
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
index 06331c886..95fc0b68b 100644
--- a/src/state/queries/video/video.ts
+++ b/src/state/queries/video/video.ts
@@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
 import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
-import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
+import {
+  ServerError,
+  UploadLimitError,
+  VideoTooLargeError,
+} from 'lib/media/video/errors'
 import {CompressedVideo} from 'lib/media/video/types'
 import {useCompressVideoMutation} from 'state/queries/video/compress-video'
 import {useVideoAgent} from 'state/queries/video/util'
@@ -149,10 +153,40 @@ export function useUploadVideo({
     onError: e => {
       if (e instanceof AbortError) {
         return
-      } else if (e instanceof ServerError) {
+      } 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
+          default:
+            message = e.message
+            break
+        }
         dispatch({
           type: 'SetError',
-          error: e.message,
+          error: message,
         })
       } else {
         dispatch({
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index b8fd15950..88537956e 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -42,7 +42,7 @@ export function VideoPreview({
     ref.current.addEventListener(
       'error',
       () => {
-        Toast.show(_(msg`Could not process your video`))
+        Toast.show(_(msg`Could not process your video`), 'xmark')
         clear()
       },
       {signal},