[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 <me@haileyok.com>
zio/stable
Samuel Newman 2024-09-03 22:49:19 +01:00 committed by GitHub
parent 0bd0146efb
commit 3ee5ef32d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 209 additions and 144 deletions

View File

@ -1,4 +1,4 @@
import React, {useCallback} from 'react' import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@ -25,7 +25,7 @@ type Action =
| {type: 'SetDimensions'; width: number; height: number} | {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo} | {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
| {type: 'SetBlobRef'; blobRef: BlobRef} | {type: 'SetComplete'; blobRef: BlobRef}
export interface State { export interface State {
status: Status status: Status
@ -36,6 +36,7 @@ export interface State {
blobRef?: BlobRef blobRef?: BlobRef
error?: string error?: string
abortController: AbortController abortController: AbortController
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
} }
function reducer(queryClient: QueryClient) { function reducer(queryClient: QueryClient) {
@ -77,8 +78,15 @@ function reducer(queryClient: QueryClient) {
updatedState = {...state, video: action.video, status: 'uploading'} updatedState = {...state, video: action.video, status: 'uploading'}
} else if (action.type === 'SetJobStatus') { } else if (action.type === 'SetJobStatus') {
updatedState = {...state, jobStatus: action.jobStatus} updatedState = {...state, jobStatus: action.jobStatus}
} else if (action.type === 'SetBlobRef') { } else if (action.type === 'SetComplete') {
updatedState = {...state, blobRef: action.blobRef, status: 'done'} updatedState = {
...state,
pendingPublish: {
blobRef: action.blobRef,
mutableProcessed: false,
},
status: 'done',
}
} }
return updatedState return updatedState
} }
@ -86,7 +94,6 @@ function reducer(queryClient: QueryClient) {
export function useUploadVideo({ export function useUploadVideo({
setStatus, setStatus,
onSuccess,
}: { }: {
setStatus: (status: string) => void setStatus: (status: string) => void
onSuccess: () => void onSuccess: () => void
@ -112,11 +119,16 @@ export function useUploadVideo({
}, },
onSuccess: blobRef => { onSuccess: blobRef => {
dispatch({ dispatch({
type: 'SetBlobRef', type: 'SetComplete',
blobRef, blobRef,
}) })
onSuccess()
}, },
onError: useCallback(() => {
dispatch({
type: 'SetError',
error: _(msg`Video failed to process`),
})
}, [_]),
}) })
const {mutate: onVideoCompressed} = useUploadVideoMutation({ const {mutate: onVideoCompressed} = useUploadVideoMutation({
@ -215,15 +227,17 @@ export function useUploadVideo({
const useUploadStatusQuery = ({ const useUploadStatusQuery = ({
onStatusChange, onStatusChange,
onSuccess, onSuccess,
onError,
}: { }: {
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
onSuccess: (blobRef: BlobRef) => void onSuccess: (blobRef: BlobRef) => void
onError: (error: Error) => void
}) => { }) => {
const videoAgent = useVideoAgent() const videoAgent = useVideoAgent()
const [enabled, setEnabled] = React.useState(true) const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState<string>() const [jobId, setJobId] = React.useState<string>()
const {isLoading, isError} = useQuery({ const {error} = useQuery({
queryKey: ['video', 'upload status', jobId], queryKey: ['video', 'upload status', jobId],
queryFn: async () => { queryFn: async () => {
if (!jobId) return // this won't happen, can ignore if (!jobId) return // this won't happen, can ignore
@ -245,9 +259,14 @@ const useUploadStatusQuery = ({
refetchInterval: 1500, refetchInterval: 1500,
}) })
useEffect(() => {
if (error) {
onError(error)
setEnabled(false)
}
}, [error, onError])
return { return {
isLoading,
isError,
setJobId: (_jobId: string) => { setJobId: (_jobId: string) => {
setJobId(_jobId) setJobId(_jobId)
setEnabled(true) setEnabled(true)

View File

@ -190,6 +190,7 @@ export const ComposePost = observer(function ComposePost({
} }
}, },
}) })
const [publishOnUpload, setPublishOnUpload] = useState(false) const [publishOnUpload, setPublishOnUpload] = useState(false)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
@ -303,147 +304,187 @@ export const ComposePost = observer(function ComposePost({
return false return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
const onPressPublish = async (finishedUploading?: boolean) => { const onPressPublish = React.useCallback(
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { async (finishedUploading?: boolean) => {
return if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
} return
}
if (isAltTextRequiredAndMissing) { if (isAltTextRequiredAndMissing) {
return return
} }
if ( if (
!finishedUploading && !finishedUploading &&
videoUploadState.asset && videoUploadState.asset &&
videoUploadState.status !== 'done' videoUploadState.status !== 'done'
) { ) {
setPublishOnUpload(true) setPublishOnUpload(true)
return return
} }
setError('') setError('')
if ( if (
richtext.text.trim().length === 0 && richtext.text.trim().length === 0 &&
gallery.isEmpty && gallery.isEmpty &&
!extLink && !extLink &&
!quote !quote
) { ) {
setError(_(msg`Did you want to say anything?`)) setError(_(msg`Did you want to say anything?`))
return return
} }
if (extLink?.isLoading) { if (extLink?.isLoading) {
setError(_(msg`Please wait for your link card to finish loading`)) setError(_(msg`Please wait for your link card to finish loading`))
return return
} }
setIsProcessing(true) setIsProcessing(true)
let postUri 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
try { try {
await whenAppViewReady(agent, postUri, res => { postUri = (
const thread = res.data.thread await apilib.post(agent, {
return AppBskyFeedDefs.isThreadViewPost(thread) rawText: richtext.text,
}) replyTo: replyTo?.uri,
} catch (waitErr: any) { images: gallery.images,
logger.error(waitErr, { quote,
message: `Waiting for app view failed`, extLink,
}) labels,
// Keep going because the post *was* published. threadgate: threadgateAllowUISettings,
} postgate,
} catch (e: any) { onStateChange: setProcessingState,
logger.error(e, { langs: toPostLanguages(langPrefs.postLanguage),
message: `Composer: create post failed`, video: videoUploadState.pendingPublish?.blobRef
hasImages: gallery.size > 0, ? {
}) blobRef: videoUploadState.pendingPublish.blobRef,
altText: videoAltText,
if (extLink) { captions: captions,
setExtLink({ aspectRatio: videoUploadState.asset
...extLink, ? {
isLoading: true, width: videoUploadState.asset?.width,
localThumb: undefined, height: videoUploadState.asset?.height,
} as apilib.ExternalEmbedDraft) }
} : undefined,
let err = cleanError(e.message) }
if (err.includes('not locate record')) { : undefined,
err = _( })
msg`We're sorry! The post you are replying to has been deleted.`, ).uri
) try {
} await whenAppViewReady(agent, postUri, res => {
setError(err) const thread = res.data.thread
setIsProcessing(false) return AppBskyFeedDefs.isThreadViewPost(thread)
return })
} finally { } catch (waitErr: any) {
if (postUri) { logger.error(waitErr, {
logEvent('post:create', { message: `Waiting for app view failed`,
imageCount: gallery.size, })
isReply: replyTo != null, // Keep going because the post *was* published.
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 } catch (e: any) {
}) logger.error(e, {
} else { message: `Composer: create post failed`,
onPost?.(postUri) 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() }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
Toast.show(
replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
}
const canPost = useMemo( const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, () => 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 // 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.status === 'compressing' || state.status === 'uploading'
? state.progress ? state.progress
: 100 : 100
if (state.error) {
text = _('Error')
progress = 100
}
return ( return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle <ProgressCircle
size={30} size={30}
borderWidth={1} borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor} borderColor={t.atoms.border_contrast_low.borderColor}
color={t.palette.primary_500} color={state.error ? t.palette.negative_500 : t.palette.primary_500}
progress={progress} progress={progress}
/> />
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText> <NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>