From 7a08d61d889328ff5e3b8ba61faab71a5568df2f Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 1 Nov 2024 03:45:55 +0000 Subject: Thread composer UI (#6050) * Basic adding of posts * Switch active post on focus * Conditionally show plus button * Insert posts midthread * Track active/inactive post * Delete posts in a thread * Focus after deletion * Tweak empty post detection * Mix height for active only * Move toolbar with post on web * Fix footer positioning * Post All button * Fix reply to positioning * Improve memoization * Improve memoization for clearVideo * Remove unnecessary argument * Add some manual memoization to fix re-renders * Scroll to bottom on add new * Fix opacity on Android * Add backdrop * Fix videos * Check alt for video too * Clear pending publish on error * Fork alt message by type * Separate placeholder for next posts * Limit hitslop to avoid clashes --- src/view/com/composer/Composer.tsx | 540 +++++++++++++++++++++++-------------- 1 file changed, 341 insertions(+), 199 deletions(-) (limited to 'src/view/com/composer/Composer.tsx') diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 129869e47..3c9808448 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -28,6 +28,9 @@ import Animated, { interpolateColor, LayoutAnimationConfig, LinearTransition, + runOnUI, + scrollTo, + useAnimatedRef, useAnimatedStyle, useDerivedValue, useSharedValue, @@ -167,9 +170,10 @@ export const ComposePost = ({ createComposerState, ) - // TODO: Display drafts for other posts in the thread. const thread = composerState.thread const activePost = thread.posts[composerState.activePostIndex] + const nextPost: PostDraft | undefined = + thread.posts[composerState.activePostIndex + 1] const dispatch = useCallback( (postAction: PostAction) => { composerDispatch({ @@ -226,20 +230,15 @@ export const ComposePost = ({ const clearVideo = React.useCallback( (postId: string) => { - const post = thread.posts.find(p => p.id === postId) - const postMedia = post?.embed.media - if (postMedia?.type === 'video') { - postMedia.video.abortController.abort() - composerDispatch({ - type: 'update_post', - postId: postId, - postAction: { - type: 'embed_remove_video', - }, - }) - } + composerDispatch({ + type: 'update_post', + postId: postId, + postAction: { + type: 'embed_remove_video', + }, + }) }, - [thread, composerDispatch], + [composerDispatch], ) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -297,180 +296,189 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) - const isAltTextRequiredAndMissing = useMemo(() => { + const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { - return false + return } - return thread.posts.some(post => { - const media = post.embed.media + for (let i = 0; i < thread.posts.length; i++) { + const media = thread.posts[i].embed.media if (media) { if (media.type === 'images' && media.images.some(img => !img.alt)) { - return true + return _(msg`One or more images is missing alt text.`) } if (media.type === 'gif' && !media.alt) { - return true + return _(msg`One or more GIFs is missing alt text.`) + } + if ( + media.type === 'video' && + media.video.status !== 'error' && + !media.video.altText + ) { + return _(msg`One or more videos is missing alt text.`) } } - }) - }, [thread, requireAltTextEnabled]) + } + }, [thread, requireAltTextEnabled, _]) const canPost = - !isAltTextRequiredAndMissing && + !missingAltError && thread.posts.every( post => post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && - !( - post.richtext.text.trim().length === 0 && - !post.embed.link && - !post.embed.media && - !post.embed.quote - ) && + !isEmptyPost(post) && !( post.embed.media?.type === 'video' && post.embed.media.video.status === 'error' ), ) - const onPressPublish = React.useCallback( - async (finishedUploading: boolean) => { - if (isPublishing) { - return - } - - if (!canPost) { - return - } + const onPressPublish = React.useCallback(async () => { + if (isPublishing) { + return + } - if ( - !finishedUploading && - thread.posts.some( - post => - post.embed.media?.type === 'video' && - post.embed.media.video.asset && - post.embed.media.video.status !== 'done', - ) - ) { - setPublishOnUpload(true) - return - } + if (!canPost) { + return + } - setError('') - setIsPublishing(true) + if ( + thread.posts.some( + post => + post.embed.media?.type === 'video' && + post.embed.media.video.asset && + post.embed.media.video.status !== 'done', + ) + ) { + setPublishOnUpload(true) + return + } - let postUri + setError('') + setIsPublishing(true) + + let postUri + try { + postUri = ( + await apilib.post(agent, queryClient, { + thread, + replyTo: replyTo?.uri, + onStateChange: setPublishingStage, + langs: toPostLanguages(langPrefs.postLanguage), + }) + ).uris[0] try { - postUri = ( - await apilib.post(agent, queryClient, { - thread, - replyTo: replyTo?.uri, - onStateChange: setPublishingStage, - langs: toPostLanguages(langPrefs.postLanguage), - }) - ).uris[0] - try { - await whenAppViewReady(agent, postUri, res => { - const postedThread = res.data.thread - return AppBskyFeedDefs.isThreadViewPost(postedThread) - }) - } 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: thread.posts.some(p => p.embed.media?.type === 'images'), + await whenAppViewReady(agent, postUri, res => { + const postedThread = res.data.thread + return AppBskyFeedDefs.isThreadViewPost(postedThread) }) - - 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.`, - ) - } else if (e instanceof EmbeddingDisabledError) { - err = _(msg`This post's author has disabled quote posts.`) - } - setError(err) - setIsPublishing(false) - return - } finally { - if (postUri) { - let index = 0 - for (let post of thread.posts) { - logEvent('post:create', { - imageCount: - post.embed.media?.type === 'images' - ? post.embed.media.images.length - : 0, - isReply: index > 0 || !!replyTo, - hasLink: !!post.embed.link, - hasQuote: !!post.embed.quote, - langs: langPrefs.postLanguage, - logContext: 'Composer', - }) - index++ - } - } + } catch (waitErr: any) { + logger.error(waitErr, { + message: `Waiting for app view failed`, + }) + // Keep going because the post *was* published. } - if (postUri && !replyTo) { - emitPostCreated() + } catch (e: any) { + logger.error(e, { + message: `Composer: create post failed`, + hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), + }) + + 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.`, + ) + } else if (e instanceof EmbeddingDisabledError) { + err = _(msg`This post's author has disabled quote posts.`) } - setLangPrefs.savePostLanguageToHistory() - if (initQuote) { - // We want to wait for the quote count to update before we call `onPost`, which will refetch data - whenAppViewReady(agent, initQuote.uri, res => { - const quotedThread = res.data.thread - if ( - AppBskyFeedDefs.isThreadViewPost(quotedThread) && - quotedThread.post.quoteCount !== initQuote.quoteCount - ) { - onPost?.(postUri) - return true - } - return false - }) - } else { - onPost?.(postUri) + setError(err) + setIsPublishing(false) + return + } finally { + if (postUri) { + let index = 0 + for (let post of thread.posts) { + logEvent('post:create', { + imageCount: + post.embed.media?.type === 'images' + ? post.embed.media.images.length + : 0, + isReply: index > 0 || !!replyTo, + hasLink: !!post.embed.link, + hasQuote: !!post.embed.quote, + langs: langPrefs.postLanguage, + logContext: 'Composer', + }) + index++ + } } - onClose() - Toast.show( - replyTo - ? _(msg`Your reply has been published`) - : _(msg`Your post has been published`), - ) - }, - [ - _, - agent, - thread, - canPost, - isPublishing, - langPrefs.postLanguage, - onClose, - onPost, - initQuote, - replyTo, - setLangPrefs, - queryClient, - ], - ) + } + if (postUri && !replyTo) { + emitPostCreated() + } + setLangPrefs.savePostLanguageToHistory() + if (initQuote) { + // We want to wait for the quote count to update before we call `onPost`, which will refetch data + whenAppViewReady(agent, initQuote.uri, res => { + const quotedThread = res.data.thread + if ( + AppBskyFeedDefs.isThreadViewPost(quotedThread) && + quotedThread.post.quoteCount !== initQuote.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, + thread, + canPost, + isPublishing, + langPrefs.postLanguage, + onClose, + onPost, + initQuote, + replyTo, + setLangPrefs, + queryClient, + ]) + + // Preserves the referential identity passed to each post item. + // Avoids re-rendering all posts on each keystroke. + const onComposerPostPublish = useNonReactiveCallback(() => { + onPressPublish() + }) React.useEffect(() => { if (publishOnUpload) { + let erroredVideos = 0 let uploadingVideos = 0 for (let post of thread.posts) { if (post.embed.media?.type === 'video') { const video = post.embed.media.video - if (!video.pendingPublish) { + if (video.status === 'error') { + erroredVideos++ + } else if (video.status !== 'done') { uploadingVideos++ } } } - if (uploadingVideos === 0) { + if (erroredVideos > 0) { + setPublishOnUpload(false) + } else if (uploadingVideos === 0) { setPublishOnUpload(false) - onPressPublish(true) + onPressPublish() } } }, [thread.posts, onPressPublish, publishOnUpload]) @@ -495,7 +503,16 @@ export const ComposePost = ({ openEmojiPicker?.(textInput.current?.getCursorPosition()) }, [openEmojiPicker]) + const scrollViewRef = useAnimatedRef() + useEffect(() => { + if (composerState.mutableNeedsFocusActive) { + composerState.mutableNeedsFocusActive = false + textInput.current?.focus() + } + }, [composerState]) + const { + contentHeight, scrollHandler, onScrollViewContentSizeChange, onScrollViewLayout, @@ -503,8 +520,44 @@ export const ComposePost = ({ bottomBarAnimatedStyle, } = useAnimatedBorders() + useEffect(() => { + if (composerState.mutableNeedsScrollToBottom) { + composerState.mutableNeedsScrollToBottom = false + runOnUI(scrollTo)(scrollViewRef, 0, contentHeight.value, true) + } + }, [composerState, scrollViewRef, contentHeight]) + const keyboardVerticalOffset = useKeyboardVerticalOffset() + const footer = ( + <> + + + { + composerDispatch({ + type: 'add_post', + }) + }} + /> + + ) + + const isFooterSticky = !isNative && thread.posts.length > 1 return ( 1} publishingStage={publishingStage} topBarAnimatedStyle={topBarAnimatedStyle} onCancel={onPressCancel} - onPublish={() => onPressPublish(false)}> - {isAltTextRequiredAndMissing && } + onPublish={onPressPublish}> + {missingAltError && } {replyTo ? : undefined} - selectVideo(activePost.id, asset)} - onClearVideo={() => clearVideo(activePost.id)} - onPublish={() => onPressPublish(false)} - onError={setError} - /> + {thread.posts.map((post, index) => ( + + 0 || !!replyTo} + isActive={post.id === activePost.id} + canRemovePost={thread.posts.length > 1} + canRemoveQuote={index > 0 || !initQuote} + onSelectVideo={selectVideo} + onClearVideo={clearVideo} + onPublish={onComposerPostPublish} + onError={setError} + /> + {isFooterSticky && post.id === activePost.id && footer} + + ))} - - - - - selectVideo(activePost.id, asset)} - /> - + {!isFooterSticky && footer} void textInput: React.Ref + isActive: boolean isReply: boolean + isFirstPost: boolean + canRemovePost: boolean canRemoveQuote: boolean - onClearVideo: () => void - onSelectVideo: (asset: ImagePickerAsset) => void + onClearVideo: (postId: string) => void + onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onError: (error: string) => void onPublish: (richtext: RichText) => void }) { @@ -619,10 +670,13 @@ function ComposerPost({ const {data: currentProfile} = useProfileQuery({did: currentDid}) const richtext = post.richtext const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media - const forceMinHeight = isWeb && isTextOnly + const forceMinHeight = isWeb && isTextOnly && isActive const selectTextInputPlaceholder = isReply - ? _(msg`Write your reply`) + ? isFirstPost + ? _(msg`Write your reply`) + : _(msg`Add another post`) : _(msg`What's up?`) + const discardPromptControl = Prompt.usePromptControl() const dispatchPost = useCallback( (action: PostAction) => { @@ -655,17 +709,17 @@ function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { if (uri.startsWith('data:video/')) { - onSelectVideo({uri, type: 'video', height: 0, width: 0}) + onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) } else { const res = await pasteImage(uri) onImageAdd([res]) } }, - [onSelectVideo, onImageAdd], + [post.id, onSelectVideo, onImageAdd], ) return ( - <> + { dispatchPost({type: 'update_richtext', richtext: rt}) }} + onFocus={() => { + dispatch({ + type: 'focus_post', + postId: post.id, + }) + }} onPhotoPasted={onPhotoPasted} onNewLink={onNewLink} onError={onError} @@ -697,21 +757,65 @@ function ComposerPost({ /> + {canRemovePost && isActive && ( + <> + + { + dispatch({ + type: 'remove_post', + postId: post.id, + }) + }} + confirmButtonCta={_(msg`Discard`)} + confirmButtonColor="negative" + /> + + )} + onClearVideo(post.id)} + isActivePost={isActive} /> - + ) -} +}) function ComposerTopBar({ canPost, isReply, isPublishQueued, isPublishing, + isThread, publishingStage, onCancel, onPublish, @@ -723,6 +827,7 @@ function ComposerTopBar({ canPost: boolean isReply: boolean isPublishQueued: boolean + isThread: boolean onCancel: () => void onPublish: () => void topBarAnimatedStyle: StyleProp @@ -769,6 +874,8 @@ function ComposerTopBar({ {isReply ? ( Reply + ) : isThread ? ( + Post All ) : ( Post )} @@ -781,7 +888,7 @@ function ComposerTopBar({ ) } -function AltTextReminder() { +function AltTextReminder({error}: {error: string}) { const pal = usePalette('default') return ( @@ -792,9 +899,7 @@ function AltTextReminder() { size={10} /> - - One or more images is missing alt text. - + {error} ) } @@ -804,11 +909,13 @@ function ComposerEmbeds({ dispatch, clearVideo, canRemoveQuote, + isActivePost, }: { embed: EmbedDraft dispatch: (action: PostAction) => void clearVideo: () => void canRemoveQuote: boolean + isActivePost: boolean }) { const video = embed.media?.type === 'video' ? embed.media.video : null return ( @@ -860,6 +967,7 @@ function ComposerEmbeds({ { dispatch({ type: 'embed_update_video', @@ -988,15 +1096,19 @@ function ComposerPills({ function ComposerFooter({ post, dispatch, + showAddButton, onEmojiButtonPress, onError, onSelectVideo, + onAddPost, }: { post: PostDraft dispatch: (action: PostAction) => void + showAddButton: boolean onEmojiButtonPress: () => void onError: (error: string) => void - onSelectVideo: (asset: ImagePickerAsset) => void + onSelectVideo: (postId: string, asset: ImagePickerAsset) => void + onAddPost: () => void }) { const t = useTheme() const {_} = useLingui() @@ -1047,7 +1159,7 @@ function ComposerFooter({ onAdd={onImageAdd} /> onSelectVideo(post.id, asset)} disabled={!!media} setError={onError} /> @@ -1072,6 +1184,21 @@ function ComposerFooter({ )} + {showAddButton && ( + + )}