Bluesky app fork with some witchin' additions 💫

[Video] Add uploaded video to post (#4884)

* video uploads!

* use video upload lexicons

* add missing postgate

* remove references to prerelease package

* fix scrubber showing a "0"

* Delete types.ts

* rm logs

* rm upload header

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>

authored by samuel.fm

Samuel Newman and committed by
GitHub
551c4a4f d52d2962

+116 -126
+27 -11
src/lib/api/index.ts
··· 3 3 AppBskyEmbedImages, 4 4 AppBskyEmbedRecord, 5 5 AppBskyEmbedRecordWithMedia, 6 + AppBskyEmbedVideo, 6 7 AppBskyFeedPostgate, 8 + AtUri, 9 + BlobRef, 7 10 BskyAgent, 8 11 ComAtprotoLabelDefs, 9 12 RichText, 10 13 } from '@atproto/api' 11 - import {AtUri} from '@atproto/api' 12 14 13 15 import {logger} from '#/logger' 14 16 import {writePostgateRecord} from '#/state/queries/postgate' ··· 43 45 uri: string 44 46 cid: string 45 47 } 46 - video?: { 47 - uri: string 48 - cid: string 49 - } 48 + video?: BlobRef 50 49 extLink?: ExternalEmbedDraft 51 50 images?: ImageModel[] 52 51 labels?: string[] ··· 61 60 | AppBskyEmbedImages.Main 62 61 | AppBskyEmbedExternal.Main 63 62 | AppBskyEmbedRecord.Main 63 + | AppBskyEmbedVideo.Main 64 64 | AppBskyEmbedRecordWithMedia.Main 65 65 | undefined 66 66 let reply 67 - let rt = new RichText( 68 - {text: opts.rawText.trimEnd()}, 69 - { 70 - cleanNewlines: true, 71 - }, 72 - ) 67 + let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) 73 68 74 69 opts.onStateChange?.('Processing...') 70 + 75 71 await rt.detectFacets(agent) 72 + 76 73 rt = shortenLinks(rt) 77 74 rt = stripInvalidMentions(rt) 78 75 ··· 126 123 $type: 'app.bsky.embed.images', 127 124 images, 128 125 } as AppBskyEmbedImages.Main 126 + } 127 + } 128 + 129 + // add video embed if present 130 + if (opts.video) { 131 + if (opts.quote) { 132 + embed = { 133 + $type: 'app.bsky.embed.recordWithMedia', 134 + record: embed, 135 + media: { 136 + $type: 'app.bsky.embed.video', 137 + video: opts.video, 138 + } as AppBskyEmbedVideo.Main, 139 + } as AppBskyEmbedRecordWithMedia.Main 140 + } else { 141 + embed = { 142 + $type: 'app.bsky.embed.video', 143 + video: opts.video, 144 + } as AppBskyEmbedVideo.Main 129 145 } 130 146 } 131 147
-36
src/lib/media/video/types.ts
··· 1 - /** 2 - * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION. 3 - * @temporary 4 - * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented. 5 - * Not joking, this is temporary. 6 - */ 7 - 8 - export interface JobStatus { 9 - jobId: string 10 - did: string 11 - cid: string 12 - state: JobState 13 - progress?: number 14 - errorHuman?: string 15 - errorMachine?: string 16 - } 17 - 18 - export enum JobState { 19 - JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED', 20 - JOB_STATE_CREATED = 'JOB_STATE_CREATED', 21 - JOB_STATE_ENCODING = 'JOB_STATE_ENCODING', 22 - JOB_STATE_ENCODED = 'JOB_STATE_ENCODED', 23 - JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING', 24 - JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED', 25 - JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING', 26 - JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED', 27 - JOB_STATE_FAILED = 'JOB_STATE_FAILED', 28 - JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED', 29 - } 30 - 31 - export interface UploadVideoResponse { 32 - job_id: string 33 - did: string 34 - cid: string 35 - state: JobState 36 - }
+11
src/state/queries/video/util.ts
··· 1 + import {useMemo} from 'react' 2 + import {AtpAgent} from '@atproto/api' 3 + 1 4 const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? '' 2 5 3 6 export const createVideoEndpointUrl = ( ··· 13 16 } 14 17 return url.href 15 18 } 19 + 20 + export function useVideoAgent() { 21 + return useMemo(() => { 22 + return new AtpAgent({ 23 + service: UPLOAD_ENDPOINT, 24 + }) 25 + }, []) 26 + }
+9 -14
src/state/queries/video/video-upload.ts
··· 1 1 import {createUploadTask, FileSystemUploadType} from 'expo-file-system' 2 + import {AppBskyVideoDefs} from '@atproto/api' 2 3 import {useMutation} from '@tanstack/react-query' 3 4 import {nanoid} from 'nanoid/non-secure' 4 5 5 6 import {CompressedVideo} from '#/lib/media/video/compress' 6 - import {UploadVideoResponse} from '#/lib/media/video/types' 7 7 import {createVideoEndpointUrl} from '#/state/queries/video/util' 8 8 import {useAgent, useSession} from '#/state/session' 9 9 10 - const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' 11 - 12 10 export const useUploadVideoMutation = ({ 13 11 onSuccess, 14 12 onError, 15 13 setProgress, 16 14 }: { 17 - onSuccess: (response: UploadVideoResponse) => void 15 + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 18 16 onError: (e: any) => void 19 17 setProgress: (progress: number) => void 20 18 }) => { ··· 23 21 24 22 return useMutation({ 25 23 mutationFn: async (video: CompressedVideo) => { 26 - const uri = createVideoEndpointUrl('/upload', { 24 + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 27 25 did: currentAccount!.did, 28 26 name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? 29 27 }) ··· 33 31 throw new Error('Agent does not have a PDS URL') 34 32 } 35 33 36 - const {data: serviceAuth} = 37 - await agent.api.com.atproto.server.getServiceAuth({ 34 + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth( 35 + { 38 36 aud: `did:web:${agent.pdsUrl.hostname}`, 39 37 lxm: 'com.atproto.repo.uploadBlob', 40 - }) 38 + }, 39 + ) 41 40 42 41 const uploadTask = createUploadTask( 43 42 uri, 44 43 video.uri, 45 44 { 46 45 headers: { 47 - 'dev-key': UPLOAD_HEADER, 48 - 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4? 46 + 'content-type': 'video/mp4', 49 47 Authorization: `Bearer ${serviceAuth.token}`, 50 48 }, 51 49 httpMethod: 'POST', ··· 59 57 throw new Error('No response') 60 58 } 61 59 62 - // @TODO rm, useful for debugging/getting video cid 63 - console.log('[VIDEO]', res.body) 64 - const responseBody = JSON.parse(res.body) as UploadVideoResponse 65 - onSuccess(responseBody) 60 + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 66 61 return responseBody 67 62 }, 68 63 onError,
+33 -36
src/state/queries/video/video-upload.web.ts
··· 1 + import {AppBskyVideoDefs} from '@atproto/api' 1 2 import {useMutation} from '@tanstack/react-query' 2 3 import {nanoid} from 'nanoid/non-secure' 3 4 4 5 import {CompressedVideo} from '#/lib/media/video/compress' 5 - import {UploadVideoResponse} from '#/lib/media/video/types' 6 6 import {createVideoEndpointUrl} from '#/state/queries/video/util' 7 7 import {useAgent, useSession} from '#/state/session' 8 8 9 - const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? '' 10 - 11 9 export const useUploadVideoMutation = ({ 12 10 onSuccess, 13 11 onError, 14 12 setProgress, 15 13 }: { 16 - onSuccess: (response: UploadVideoResponse) => void 14 + onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 17 15 onError: (e: any) => void 18 16 setProgress: (progress: number) => void 19 17 }) => { ··· 22 20 23 21 return useMutation({ 24 22 mutationFn: async (video: CompressedVideo) => { 25 - const uri = createVideoEndpointUrl('/upload', { 23 + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 26 24 did: currentAccount!.did, 27 - name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? 25 + name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' 28 26 }) 29 27 30 28 // a logged-in agent should have this set, but we'll check just in case ··· 32 30 throw new Error('Agent does not have a PDS URL') 33 31 } 34 32 35 - const {data: serviceAuth} = 36 - await agent.api.com.atproto.server.getServiceAuth({ 33 + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth( 34 + { 37 35 aud: `did:web:${agent.pdsUrl.hostname}`, 38 36 lxm: 'com.atproto.repo.uploadBlob', 39 - }) 37 + }, 38 + ) 40 39 41 40 const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) 42 41 43 42 const xhr = new XMLHttpRequest() 44 - const res = (await new Promise((resolve, reject) => { 45 - xhr.upload.addEventListener('progress', e => { 46 - const progress = e.loaded / e.total 47 - setProgress(progress) 48 - }) 49 - xhr.onloadend = () => { 50 - if (xhr.readyState === 4) { 51 - const uploadRes = JSON.parse( 52 - xhr.responseText, 53 - ) as UploadVideoResponse 54 - resolve(uploadRes) 55 - onSuccess(uploadRes) 56 - } else { 43 + const res = await new Promise<AppBskyVideoDefs.JobStatus>( 44 + (resolve, reject) => { 45 + xhr.upload.addEventListener('progress', e => { 46 + const progress = e.loaded / e.total 47 + setProgress(progress) 48 + }) 49 + xhr.onloadend = () => { 50 + if (xhr.readyState === 4) { 51 + const uploadRes = JSON.parse( 52 + xhr.responseText, 53 + ) as AppBskyVideoDefs.JobStatus 54 + resolve(uploadRes) 55 + onSuccess(uploadRes) 56 + } else { 57 + reject() 58 + onError(new Error('Failed to upload video')) 59 + } 60 + } 61 + xhr.onerror = () => { 57 62 reject() 58 63 onError(new Error('Failed to upload video')) 59 64 } 60 - } 61 - xhr.onerror = () => { 62 - reject() 63 - onError(new Error('Failed to upload video')) 64 - } 65 - xhr.open('POST', uri) 66 - xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type? 67 - // @TODO remove this header for prod 68 - xhr.setRequestHeader('dev-key', UPLOAD_HEADER) 69 - xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) 70 - xhr.send(bytes) 71 - })) as UploadVideoResponse 65 + xhr.open('POST', uri) 66 + xhr.setRequestHeader('Content-Type', 'video/mp4') 67 + xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`) 68 + xhr.send(bytes) 69 + }, 70 + ) 72 71 73 - // @TODO rm for prod 74 - console.log('[VIDEO]', res) 75 72 return res 76 73 }, 77 74 onError,
+33 -27
src/state/queries/video/video.ts
··· 1 1 import React from 'react' 2 2 import {ImagePickerAsset} from 'expo-image-picker' 3 + import {AppBskyVideoDefs, BlobRef} from '@atproto/api' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 import {useQuery} from '@tanstack/react-query' ··· 7 8 import {logger} from '#/logger' 8 9 import {CompressedVideo} from 'lib/media/video/compress' 9 10 import {VideoTooLargeError} from 'lib/media/video/errors' 10 - import {JobState, JobStatus} from 'lib/media/video/types' 11 11 import {useCompressVideoMutation} from 'state/queries/video/compress-video' 12 - import {createVideoEndpointUrl} from 'state/queries/video/util' 12 + import {useVideoAgent} from 'state/queries/video/util' 13 13 import {useUploadVideoMutation} from 'state/queries/video/video-upload' 14 14 15 15 type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done' 16 16 17 17 type Action = 18 - | { 19 - type: 'SetStatus' 20 - status: Status 21 - } 22 - | { 23 - type: 'SetProgress' 24 - progress: number 25 - } 26 - | { 27 - type: 'SetError' 28 - error: string | undefined 29 - } 18 + | {type: 'SetStatus'; status: Status} 19 + | {type: 'SetProgress'; progress: number} 20 + | {type: 'SetError'; error: string | undefined} 30 21 | {type: 'Reset'} 31 22 | {type: 'SetAsset'; asset: ImagePickerAsset} 32 23 | {type: 'SetVideo'; video: CompressedVideo} 33 - | {type: 'SetJobStatus'; jobStatus: JobStatus} 24 + | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} 25 + | {type: 'SetBlobRef'; blobRef: BlobRef} 34 26 35 27 export interface State { 36 28 status: Status 37 29 progress: number 38 30 asset?: ImagePickerAsset 39 31 video: CompressedVideo | null 40 - jobStatus?: JobStatus 32 + jobStatus?: AppBskyVideoDefs.JobStatus 33 + blobRef?: BlobRef 41 34 error?: string 42 35 } 43 36 ··· 54 47 status: 'idle', 55 48 progress: 0, 56 49 video: null, 50 + blobRef: undefined, 57 51 } 58 52 } else if (action.type === 'SetAsset') { 59 53 updatedState = {...state, asset: action.asset} ··· 61 55 updatedState = {...state, video: action.video} 62 56 } else if (action.type === 'SetJobStatus') { 63 57 updatedState = {...state, jobStatus: action.jobStatus} 58 + } else if (action.type === 'SetBlobRef') { 59 + updatedState = {...state, blobRef: action.blobRef} 64 60 } 65 61 return updatedState 66 62 } ··· 80 76 }) 81 77 82 78 const {setJobId} = useUploadStatusQuery({ 83 - onStatusChange: (status: JobStatus) => { 79 + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => { 84 80 // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user 85 81 // Leaving it for now though 86 82 dispatch({ ··· 89 85 }) 90 86 setStatus(status.state.toString()) 91 87 }, 92 - onSuccess: () => { 88 + onSuccess: blobRef => { 89 + dispatch({ 90 + type: 'SetBlobRef', 91 + blobRef, 92 + }) 93 93 dispatch({ 94 94 type: 'SetStatus', 95 95 status: 'idle', ··· 104 104 type: 'SetStatus', 105 105 status: 'processing', 106 106 }) 107 - setJobId(response.job_id) 107 + setJobId(response.jobId) 108 108 }, 109 109 onError: e => { 110 110 dispatch({ ··· 179 179 onStatusChange, 180 180 onSuccess, 181 181 }: { 182 - onStatusChange: (status: JobStatus) => void 183 - onSuccess: () => void 182 + onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void 183 + onSuccess: (blobRef: BlobRef) => void 184 184 }) => { 185 + const videoAgent = useVideoAgent() 185 186 const [enabled, setEnabled] = React.useState(true) 186 187 const [jobId, setJobId] = React.useState<string>() 187 188 188 189 const {isLoading, isError} = useQuery({ 189 - queryKey: ['video-upload'], 190 + queryKey: ['video-upload', jobId], 190 191 queryFn: async () => { 191 - const url = createVideoEndpointUrl(`/job/${jobId}/status`) 192 - const res = await fetch(url) 193 - const status = (await res.json()) as JobStatus 194 - if (status.state === JobState.JOB_STATE_COMPLETED) { 192 + if (!jobId) return // this won't happen, can ignore 193 + 194 + const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId}) 195 + const status = data.jobStatus 196 + if (status.state === 'JOB_STATE_COMPLETED') { 195 197 setEnabled(false) 196 - onSuccess() 198 + if (!status.blob) 199 + throw new Error('Job completed, but did not return a blob') 200 + onSuccess(status.blob) 201 + } else if (status.state === 'JOB_STATE_FAILED') { 202 + throw new Error('Job failed to process') 197 203 } 198 204 onStatusChange(status) 199 205 return status
+2 -1
src/view/com/composer/Composer.tsx
··· 178 178 clearVideo, 179 179 state: videoUploadState, 180 180 } = useUploadVideo({ 181 - setStatus: (status: string) => setProcessingState(status), 181 + setStatus: setProcessingState, 182 182 onSuccess: () => { 183 183 if (publishOnUpload) { 184 184 onPressPublish(true) ··· 348 348 postgate, 349 349 onStateChange: setProcessingState, 350 350 langs: toPostLanguages(langPrefs.postLanguage), 351 + video: videoUploadState.blobRef, 351 352 }) 352 353 ).uri 353 354 try {
+1 -1
src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
··· 557 557 {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, 558 558 {height: hovered || scrubberActive ? 6 : 3}, 559 559 ]}> 560 - {currentTime && duration && ( 560 + {currentTime > 0 && duration > 0 && ( 561 561 <View 562 562 style={[ 563 563 a.h_full,