From 3ee5ef32d9d4342c3ce473933d84aa2ef01dd97b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 3 Sep 2024 22:49:19 +0100 Subject: [PATCH] [Video] Error handling in composer, fix auto-send (#5122) * tweak * error state for upload toolbar * catch errors in upload status query * stop query on error --------- Co-authored-by: Hailey --- src/state/queries/video/video.ts | 39 +++- src/view/com/composer/Composer.tsx | 314 +++++++++++++++++------------ 2 files changed, 209 insertions(+), 144 deletions(-) diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts index 5e36ce35..ee072449 100644 --- a/src/state/queries/video/video.ts +++ b/src/state/queries/video/video.ts @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react' +import React, {useCallback, useEffect} from 'react' import {ImagePickerAsset} from 'expo-image-picker' import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {msg} from '@lingui/macro' @@ -25,7 +25,7 @@ type Action = | {type: 'SetDimensions'; width: number; height: number} | {type: 'SetVideo'; video: CompressedVideo} | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} - | {type: 'SetBlobRef'; blobRef: BlobRef} + | {type: 'SetComplete'; blobRef: BlobRef} export interface State { status: Status @@ -36,6 +36,7 @@ export interface State { blobRef?: BlobRef error?: string abortController: AbortController + pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean} } function reducer(queryClient: QueryClient) { @@ -77,8 +78,15 @@ function reducer(queryClient: QueryClient) { updatedState = {...state, video: action.video, status: 'uploading'} } else if (action.type === 'SetJobStatus') { updatedState = {...state, jobStatus: action.jobStatus} - } else if (action.type === 'SetBlobRef') { - updatedState = {...state, blobRef: action.blobRef, status: 'done'} + } else if (action.type === 'SetComplete') { + updatedState = { + ...state, + pendingPublish: { + blobRef: action.blobRef, + mutableProcessed: false, + }, + status: 'done', + } } return updatedState } @@ -86,7 +94,6 @@ function reducer(queryClient: QueryClient) { export function useUploadVideo({ setStatus, - onSuccess, }: { setStatus: (status: string) => void onSuccess: () => void @@ -112,11 +119,16 @@ export function useUploadVideo({ }, onSuccess: blobRef => { dispatch({ - type: 'SetBlobRef', + type: 'SetComplete', blobRef, }) - onSuccess() }, + onError: useCallback(() => { + dispatch({ + type: 'SetError', + error: _(msg`Video failed to process`), + }) + }, [_]), }) const {mutate: onVideoCompressed} = useUploadVideoMutation({ @@ -215,15 +227,17 @@ export function useUploadVideo({ const useUploadStatusQuery = ({ onStatusChange, onSuccess, + onError, }: { onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void onSuccess: (blobRef: BlobRef) => void + onError: (error: Error) => void }) => { const videoAgent = useVideoAgent() const [enabled, setEnabled] = React.useState(true) const [jobId, setJobId] = React.useState() - const {isLoading, isError} = useQuery({ + const {error} = useQuery({ queryKey: ['video', 'upload status', jobId], queryFn: async () => { if (!jobId) return // this won't happen, can ignore @@ -245,9 +259,14 @@ const useUploadStatusQuery = ({ refetchInterval: 1500, }) + useEffect(() => { + if (error) { + onError(error) + setEnabled(false) + } + }, [error, onError]) + return { - isLoading, - isError, setJobId: (_jobId: string) => { setJobId(_jobId) setEnabled(true) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e42e23ba..b07adf2a 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -190,6 +190,7 @@ export const ComposePost = observer(function ComposePost({ } }, }) + const [publishOnUpload, setPublishOnUpload] = useState(false) const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) @@ -303,147 +304,187 @@ export const ComposePost = observer(function ComposePost({ return false }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) - const onPressPublish = async (finishedUploading?: boolean) => { - if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { - return - } + const onPressPublish = React.useCallback( + async (finishedUploading?: boolean) => { + if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { + return + } - if (isAltTextRequiredAndMissing) { - return - } + if (isAltTextRequiredAndMissing) { + return + } - if ( - !finishedUploading && - videoUploadState.asset && - videoUploadState.status !== 'done' - ) { - setPublishOnUpload(true) - return - } + if ( + !finishedUploading && + videoUploadState.asset && + videoUploadState.status !== 'done' + ) { + setPublishOnUpload(true) + return + } - setError('') + setError('') - if ( - richtext.text.trim().length === 0 && - gallery.isEmpty && - !extLink && - !quote - ) { - setError(_(msg`Did you want to say anything?`)) - return - } - if (extLink?.isLoading) { - setError(_(msg`Please wait for your link card to finish loading`)) - return - } + if ( + richtext.text.trim().length === 0 && + gallery.isEmpty && + !extLink && + !quote + ) { + setError(_(msg`Did you want to say anything?`)) + return + } + if (extLink?.isLoading) { + setError(_(msg`Please wait for your link card to finish loading`)) + return + } - setIsProcessing(true) + setIsProcessing(true) - let postUri - try { - postUri = ( - await apilib.post(agent, { - rawText: richtext.text, - replyTo: replyTo?.uri, - images: gallery.images, - quote, - extLink, - labels, - threadgate: threadgateAllowUISettings, - postgate, - onStateChange: setProcessingState, - langs: toPostLanguages(langPrefs.postLanguage), - video: videoUploadState.blobRef - ? { - blobRef: videoUploadState.blobRef, - altText: videoAltText, - captions: captions, - aspectRatio: videoUploadState.asset - ? { - width: videoUploadState.asset?.width, - height: videoUploadState.asset?.height, - } - : undefined, - } - : undefined, - }) - ).uri + let postUri try { - await whenAppViewReady(agent, postUri, res => { - const thread = res.data.thread - return AppBskyFeedDefs.isThreadViewPost(thread) - }) - } catch (waitErr: any) { - logger.error(waitErr, { - message: `Waiting for app view failed`, - }) - // Keep going because the post *was* published. - } - } catch (e: any) { - logger.error(e, { - message: `Composer: create post failed`, - hasImages: gallery.size > 0, - }) - - if (extLink) { - setExtLink({ - ...extLink, - isLoading: true, - localThumb: undefined, - } as apilib.ExternalEmbedDraft) - } - let err = cleanError(e.message) - if (err.includes('not locate record')) { - err = _( - msg`We're sorry! The post you are replying to has been deleted.`, - ) - } - setError(err) - setIsProcessing(false) - return - } finally { - if (postUri) { - logEvent('post:create', { - imageCount: gallery.size, - isReply: replyTo != null, - hasLink: extLink != null, - hasQuote: quote != null, - langs: langPrefs.postLanguage, - logContext: 'Composer', - }) - } - track('Create Post', { - imageCount: gallery.size, - }) - if (replyTo && replyTo.uri) track('Post:Reply') - } - if (postUri && !replyTo) { - emitPostCreated() - } - setLangPrefs.savePostLanguageToHistory() - if (quote) { - // We want to wait for the quote count to update before we call `onPost`, which will refetch data - whenAppViewReady(agent, quote.uri, res => { - const thread = res.data.thread - if ( - AppBskyFeedDefs.isThreadViewPost(thread) && - thread.post.quoteCount !== quoteCount - ) { - onPost?.(postUri) - return true + postUri = ( + await apilib.post(agent, { + rawText: richtext.text, + replyTo: replyTo?.uri, + images: gallery.images, + quote, + extLink, + labels, + threadgate: threadgateAllowUISettings, + postgate, + onStateChange: setProcessingState, + langs: toPostLanguages(langPrefs.postLanguage), + video: videoUploadState.pendingPublish?.blobRef + ? { + blobRef: videoUploadState.pendingPublish.blobRef, + altText: videoAltText, + captions: captions, + aspectRatio: videoUploadState.asset + ? { + width: videoUploadState.asset?.width, + height: videoUploadState.asset?.height, + } + : undefined, + } + : undefined, + }) + ).uri + try { + await whenAppViewReady(agent, postUri, res => { + const thread = res.data.thread + return AppBskyFeedDefs.isThreadViewPost(thread) + }) + } catch (waitErr: any) { + logger.error(waitErr, { + message: `Waiting for app view failed`, + }) + // Keep going because the post *was* published. } - return false - }) - } else { - onPost?.(postUri) + } catch (e: any) { + logger.error(e, { + message: `Composer: create post failed`, + hasImages: gallery.size > 0, + }) + + if (extLink) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) + } + let err = cleanError(e.message) + if (err.includes('not locate record')) { + err = _( + msg`We're sorry! The post you are replying to has been deleted.`, + ) + } + setError(err) + setIsProcessing(false) + return + } finally { + if (postUri) { + logEvent('post:create', { + imageCount: gallery.size, + isReply: replyTo != null, + hasLink: extLink != null, + hasQuote: quote != null, + langs: langPrefs.postLanguage, + logContext: 'Composer', + }) + } + track('Create Post', { + imageCount: gallery.size, + }) + if (replyTo && replyTo.uri) track('Post:Reply') + } + if (postUri && !replyTo) { + emitPostCreated() + } + setLangPrefs.savePostLanguageToHistory() + if (quote) { + // We want to wait for the quote count to update before we call `onPost`, which will refetch data + whenAppViewReady(agent, quote.uri, res => { + const thread = res.data.thread + if ( + AppBskyFeedDefs.isThreadViewPost(thread) && + thread.post.quoteCount !== quoteCount + ) { + onPost?.(postUri) + return true + } + return false + }) + } else { + onPost?.(postUri) + } + onClose() + Toast.show( + replyTo + ? _(msg`Your reply has been published`) + : _(msg`Your post has been published`), + ) + }, + [ + _, + agent, + captions, + extLink, + gallery.images, + gallery.isEmpty, + gallery.size, + graphemeLength, + isAltTextRequiredAndMissing, + isProcessing, + labels, + langPrefs.postLanguage, + onClose, + onPost, + postgate, + quote, + quoteCount, + replyTo, + richtext.text, + setExtLink, + setLangPrefs, + threadgateAllowUISettings, + track, + videoAltText, + videoUploadState.asset, + videoUploadState.pendingPublish, + videoUploadState.status, + ], + ) + + React.useEffect(() => { + if (videoUploadState.pendingPublish && publishOnUpload) { + if (!videoUploadState.pendingPublish.mutableProcessed) { + videoUploadState.pendingPublish.mutableProcessed = true + onPressPublish(true) + } } - onClose() - Toast.show( - replyTo - ? _(msg`Your reply has been published`) - : _(msg`Your post has been published`), - ) - } + }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish]) const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, @@ -1058,18 +1099,23 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { } // we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100 - const progress = + let progress = state.status === 'compressing' || state.status === 'uploading' ? state.progress : 100 + if (state.error) { + text = _('Error') + progress = 100 + } + return ( {text}