diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5be099d0..9bf1fb35 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 a06a239e..1c55a9ee 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 e019848a..7ea38d8d 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, ) => { - 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 00000000..6b633bf2 --- /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 23e04316..170b5389 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 40f58645..c93e2060 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( (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 06331c88..95fc0b68 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 b8fd1595..88537956 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},