Bluesky app fork with some witchin' additions 💫

[Video] Make compress/upload cancelable (#4996)

* add abort controller to video upload system

* rm log

* rm log 2

authored by samuel.fm and committed by

GitHub ea5ab993 551c4a4f

+104 -58
+20
src/lib/async/cancelable.ts
··· 1 + export function cancelable<A, T>( 2 + f: (args: A) => Promise<T>, 3 + signal: AbortSignal, 4 + ) { 5 + return (args: A) => { 6 + return new Promise<T>((resolve, reject) => { 7 + signal.addEventListener('abort', () => { 8 + reject(new AbortError()) 9 + }) 10 + f(args).then(resolve, reject) 11 + }) 12 + } 13 + } 14 + 15 + export class AbortError extends Error { 16 + constructor() { 17 + super('Aborted') 18 + this.name = 'AbortError' 19 + } 20 + }
+9 -3
src/lib/media/video/compress.ts
··· 8 8 export async function compressVideo( 9 9 file: string, 10 10 opts?: { 11 - getCancellationId?: (id: string) => void 11 + signal?: AbortSignal 12 12 onProgress?: (progress: number) => void 13 13 }, 14 14 ): Promise<CompressedVideo> { 15 - const {onProgress, getCancellationId} = opts || {} 15 + const {onProgress, signal} = opts || {} 16 16 17 17 const compressed = await Video.compress( 18 18 file, 19 19 { 20 - getCancellationId, 21 20 compressionMethod: 'manual', 22 21 bitrate: 3_000_000, // 3mbps 23 22 maxSize: 1920, 23 + getCancellationId: id => { 24 + if (signal) { 25 + signal.addEventListener('abort', () => { 26 + Video.cancelCompression(id) 27 + }) 28 + } 29 + }, 24 30 }, 25 31 onProgress, 26 32 )
+3 -2
src/lib/media/video/compress.web.ts
··· 10 10 // doesn't actually compress, but throws if >100MB 11 11 export async function compressVideo( 12 12 file: string, 13 - _callbacks?: { 14 - onProgress: (progress: number) => void 13 + _opts?: { 14 + signal?: AbortSignal 15 + onProgress?: (progress: number) => void 15 16 }, 16 17 ): Promise<CompressedVideo> { 17 18 const blob = await fetch(file).then(res => res.blob())
+12 -5
src/state/queries/video/compress-video.ts
··· 1 1 import {ImagePickerAsset} from 'expo-image-picker' 2 2 import {useMutation} from '@tanstack/react-query' 3 3 4 + import {cancelable} from '#/lib/async/cancelable' 4 5 import {CompressedVideo, compressVideo} from 'lib/media/video/compress' 5 6 6 7 export function useCompressVideoMutation({ 7 8 onProgress, 8 9 onSuccess, 9 10 onError, 11 + signal, 10 12 }: { 11 13 onProgress: (progress: number) => void 12 14 onError: (e: any) => void 13 15 onSuccess: (video: CompressedVideo) => void 16 + signal: AbortSignal 14 17 }) { 15 18 return useMutation({ 16 - mutationFn: async (asset: ImagePickerAsset) => { 17 - return await compressVideo(asset.uri, { 18 - onProgress: num => onProgress(trunc2dp(num)), 19 - }) 20 - }, 19 + mutationKey: ['video', 'compress'], 20 + mutationFn: cancelable( 21 + (asset: ImagePickerAsset) => 22 + compressVideo(asset.uri, { 23 + onProgress: num => onProgress(trunc2dp(num)), 24 + signal, 25 + }), 26 + signal, 27 + ), 21 28 onError, 22 29 onSuccess, 23 30 onMutate: () => {
+6 -2
src/state/queries/video/video-upload.ts
··· 3 3 import {useMutation} from '@tanstack/react-query' 4 4 import {nanoid} from 'nanoid/non-secure' 5 5 6 + import {cancelable} from '#/lib/async/cancelable' 6 7 import {CompressedVideo} from '#/lib/media/video/compress' 7 8 import {createVideoEndpointUrl} from '#/state/queries/video/util' 8 9 import {useAgent, useSession} from '#/state/session' ··· 11 12 onSuccess, 12 13 onError, 13 14 setProgress, 15 + signal, 14 16 }: { 15 17 onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 16 18 onError: (e: any) => void 17 19 setProgress: (progress: number) => void 20 + signal: AbortSignal 18 21 }) => { 19 22 const {currentAccount} = useSession() 20 23 const agent = useAgent() 21 24 22 25 return useMutation({ 23 - mutationFn: async (video: CompressedVideo) => { 26 + mutationKey: ['video', 'upload'], 27 + mutationFn: cancelable(async (video: CompressedVideo) => { 24 28 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 25 29 did: currentAccount!.did, 26 30 name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? ··· 59 63 60 64 const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus 61 65 return responseBody 62 - }, 66 + }, signal), 63 67 onError, 64 68 onSuccess, 65 69 })
+6 -2
src/state/queries/video/video-upload.web.ts
··· 2 2 import {useMutation} from '@tanstack/react-query' 3 3 import {nanoid} from 'nanoid/non-secure' 4 4 5 + import {cancelable} from '#/lib/async/cancelable' 5 6 import {CompressedVideo} from '#/lib/media/video/compress' 6 7 import {createVideoEndpointUrl} from '#/state/queries/video/util' 7 8 import {useAgent, useSession} from '#/state/session' ··· 10 11 onSuccess, 11 12 onError, 12 13 setProgress, 14 + signal, 13 15 }: { 14 16 onSuccess: (response: AppBskyVideoDefs.JobStatus) => void 15 17 onError: (e: any) => void 16 18 setProgress: (progress: number) => void 19 + signal: AbortSignal 17 20 }) => { 18 21 const {currentAccount} = useSession() 19 22 const agent = useAgent() 20 23 21 24 return useMutation({ 22 - mutationFn: async (video: CompressedVideo) => { 25 + mutationKey: ['video', 'upload'], 26 + mutationFn: cancelable(async (video: CompressedVideo) => { 23 27 const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { 24 28 did: currentAccount!.did, 25 29 name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' ··· 70 74 ) 71 75 72 76 return res 73 - }, 77 + }, signal), 74 78 onError, 75 79 onSuccess, 76 80 })
+38 -27
src/state/queries/video/video.ts
··· 3 3 import {AppBskyVideoDefs, BlobRef} from '@atproto/api' 4 4 import {msg} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 - import {useQuery} from '@tanstack/react-query' 6 + import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' 7 7 8 8 import {logger} from '#/logger' 9 9 import {CompressedVideo} from 'lib/media/video/compress' ··· 32 32 jobStatus?: AppBskyVideoDefs.JobStatus 33 33 blobRef?: BlobRef 34 34 error?: string 35 + abortController: AbortController 35 36 } 36 37 37 - function reducer(state: State, action: Action): State { 38 - let updatedState = state 39 - if (action.type === 'SetStatus') { 40 - updatedState = {...state, status: action.status} 41 - } else if (action.type === 'SetProgress') { 42 - updatedState = {...state, progress: action.progress} 43 - } else if (action.type === 'SetError') { 44 - updatedState = {...state, error: action.error} 45 - } else if (action.type === 'Reset') { 46 - updatedState = { 47 - status: 'idle', 48 - progress: 0, 49 - video: null, 50 - blobRef: undefined, 38 + function reducer(queryClient: QueryClient) { 39 + return (state: State, action: Action): State => { 40 + let updatedState = state 41 + if (action.type === 'SetStatus') { 42 + updatedState = {...state, status: action.status} 43 + } else if (action.type === 'SetProgress') { 44 + updatedState = {...state, progress: action.progress} 45 + } else if (action.type === 'SetError') { 46 + updatedState = {...state, error: action.error} 47 + } else if (action.type === 'Reset') { 48 + state.abortController.abort() 49 + queryClient.cancelQueries({ 50 + queryKey: ['video'], 51 + }) 52 + updatedState = { 53 + status: 'idle', 54 + progress: 0, 55 + video: null, 56 + blobRef: undefined, 57 + abortController: new AbortController(), 58 + } 59 + } else if (action.type === 'SetAsset') { 60 + updatedState = {...state, asset: action.asset} 61 + } else if (action.type === 'SetVideo') { 62 + updatedState = {...state, video: action.video} 63 + } else if (action.type === 'SetJobStatus') { 64 + updatedState = {...state, jobStatus: action.jobStatus} 65 + } else if (action.type === 'SetBlobRef') { 66 + updatedState = {...state, blobRef: action.blobRef} 51 67 } 52 - } else if (action.type === 'SetAsset') { 53 - updatedState = {...state, asset: action.asset} 54 - } else if (action.type === 'SetVideo') { 55 - updatedState = {...state, video: action.video} 56 - } else if (action.type === 'SetJobStatus') { 57 - updatedState = {...state, jobStatus: action.jobStatus} 58 - } else if (action.type === 'SetBlobRef') { 59 - updatedState = {...state, blobRef: action.blobRef} 68 + return updatedState 60 69 } 61 - return updatedState 62 70 } 63 71 64 72 export function useUploadVideo({ ··· 69 77 onSuccess: () => void 70 78 }) { 71 79 const {_} = useLingui() 72 - const [state, dispatch] = React.useReducer(reducer, { 80 + const queryClient = useQueryClient() 81 + const [state, dispatch] = React.useReducer(reducer(queryClient), { 73 82 status: 'idle', 74 83 progress: 0, 75 84 video: null, 85 + abortController: new AbortController(), 76 86 }) 77 87 78 88 const {setJobId} = useUploadStatusQuery({ ··· 116 126 setProgress: p => { 117 127 dispatch({type: 'SetProgress', progress: p}) 118 128 }, 129 + signal: state.abortController.signal, 119 130 }) 120 131 121 132 const {mutate: onSelectVideo} = useCompressVideoMutation({ ··· 148 159 }) 149 160 onVideoCompressed(video) 150 161 }, 162 + signal: state.abortController.signal, 151 163 }) 152 164 153 165 const selectVideo = (asset: ImagePickerAsset) => { ··· 163 175 } 164 176 165 177 const clearVideo = () => { 166 - // @TODO cancel any running jobs 167 178 dispatch({type: 'Reset'}) 168 179 } 169 180 ··· 187 198 const [jobId, setJobId] = React.useState<string>() 188 199 189 200 const {isLoading, isError} = useQuery({ 190 - queryKey: ['video-upload', jobId], 201 + queryKey: ['video', 'upload status', jobId], 191 202 queryFn: async () => { 192 203 if (!jobId) return // this won't happen, can ignore 193 204
+2 -8
src/view/com/composer/Composer.tsx
··· 1 1 import React, { 2 - Suspense, 3 2 useCallback, 4 3 useEffect, 5 4 useImperativeHandle, ··· 700 699 <VideoTranscodeProgress 701 700 asset={videoUploadState.asset} 702 701 progress={videoUploadState.progress} 702 + clear={clearVideo} 703 703 /> 704 704 ) : videoUploadState.video ? ( 705 - // remove suspense when we get rid of lazy 706 - <Suspense fallback={null}> 707 - <VideoPreview 708 - video={videoUploadState.video} 709 - clear={clearVideo} 710 - /> 711 - </Suspense> 705 + <VideoPreview video={videoUploadState.video} clear={clearVideo} /> 712 706 ) : null} 713 707 </View> 714 708 </Animated.ScrollView>
+2 -2
src/view/com/composer/ExternalEmbedRemoveBtn.tsx
··· 25 25 }} 26 26 onPress={onRemove} 27 27 accessibilityRole="button" 28 - accessibilityLabel={_(msg`Remove image preview`)} 29 - accessibilityHint={_(msg`Removes the image preview`)} 28 + accessibilityLabel={_(msg`Remove attachment`)} 29 + accessibilityHint={_(msg`Removes the attachment`)} 30 30 onAccessibilityEscape={onRemove}> 31 31 <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 32 32 </TouchableOpacity>
+6 -7
src/view/com/composer/videos/VideoTranscodeProgress.tsx
··· 3 3 // @ts-expect-error no type definition 4 4 import ProgressPie from 'react-native-progress/Pie' 5 5 import {ImagePickerAsset} from 'expo-image-picker' 6 - import {Trans} from '@lingui/macro' 7 6 8 7 import {atoms as a, useTheme} from '#/alf' 9 - import {Text} from '#/components/Typography' 8 + import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn' 10 9 import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' 11 10 12 11 export function VideoTranscodeProgress({ 13 12 asset, 14 13 progress, 14 + clear, 15 15 }: { 16 16 asset: ImagePickerAsset 17 17 progress: number 18 + clear: () => void 18 19 }) { 19 20 const t = useTheme() 20 21 ··· 41 42 a.inset_0, 42 43 ]}> 43 44 <ProgressPie 44 - size={64} 45 - borderWidth={4} 45 + size={48} 46 + borderWidth={3} 46 47 borderColor={t.atoms.text.color} 47 48 color={t.atoms.text.color} 48 49 progress={progress} 49 50 /> 50 - <Text> 51 - <Trans>Compressing...</Trans> 52 - </Text> 53 51 </View> 52 + <ExternalEmbedRemoveBtn onRemove={clear} /> 54 53 </View> 55 54 ) 56 55 }