diff options
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/lib/constants.ts | 1 | ||||
-rw-r--r-- | src/view/com/composer/Composer.tsx | 540 | ||||
-rw-r--r-- | src/view/com/composer/ComposerReplyTo.tsx | 1 | ||||
-rw-r--r-- | src/view/com/composer/photos/Gallery.tsx | 5 | ||||
-rw-r--r-- | src/view/com/composer/select-language/SelectLangBtn.tsx | 4 | ||||
-rw-r--r-- | src/view/com/composer/state/composer.ts | 81 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 13 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.tsx | 22 | ||||
-rw-r--r-- | src/view/com/util/forms/DropdownButton.tsx | 5 | ||||
-rw-r--r-- | yarn.lock | 8 |
11 files changed, 465 insertions, 217 deletions
diff --git a/package.json b/package.json index a5d2a43a4..d15dd232d 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/react-native-fontawesome": "^0.3.2", - "@haileyok/bluesky-video": "0.2.3", + "@haileyok/bluesky-video": "0.2.4", "@ipld/dag-cbor": "^9.2.0", "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.7.1", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1a13304fa..34847b704 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -95,6 +95,7 @@ export const HITSLOP_10 = createHitslop(10) export const HITSLOP_20 = createHitslop(20) export const HITSLOP_30 = createHitslop(30) export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} +export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4} export const BACK_HITSLOP = HITSLOP_30 export const MAX_POST_LINES = 25 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<Animated.ScrollView>() + 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 = ( + <> + <SuggestedLanguage text={activePost.richtext.text} /> + <ComposerPills + isReply={!!replyTo} + post={activePost} + thread={composerState.thread} + dispatch={composerDispatch} + bottomBarAnimatedStyle={bottomBarAnimatedStyle} + /> + <ComposerFooter + post={activePost} + dispatch={dispatch} + showAddButton={ + !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) + } + onError={setError} + onEmojiButtonPress={onEmojiButtonPress} + onSelectVideo={selectVideo} + onAddPost={() => { + composerDispatch({ + type: 'add_post', + }) + }} + /> + </> + ) + + const isFooterSticky = !isNative && thread.posts.length > 1 return ( <BottomSheetPortalProvider> <KeyboardAvoidingView @@ -521,11 +574,12 @@ export const ComposePost = ({ isReply={!!replyTo} isPublishQueued={publishOnUpload} isPublishing={isPublishing} + isThread={thread.posts.length > 1} publishingStage={publishingStage} topBarAnimatedStyle={topBarAnimatedStyle} onCancel={onPressCancel} - onPublish={() => onPressPublish(false)}> - {isAltTextRequiredAndMissing && <AltTextReminder />} + onPublish={onPressPublish}> + {missingAltError && <AltTextReminder error={missingAltError} />} <ErrorBanner error={error} videoState={erroredVideo} @@ -539,6 +593,7 @@ export const ComposePost = ({ </ComposerTopBar> <Animated.ScrollView + ref={scrollViewRef} layout={native(LinearTransition)} onScroll={scrollHandler} style={styles.scrollView} @@ -546,37 +601,27 @@ export const ComposePost = ({ onContentSizeChange={onScrollViewContentSizeChange} onLayout={onScrollViewLayout}> {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} - <ComposerPost - key={activePost.id} - post={activePost} - dispatch={composerDispatch} - textInput={textInput} - isReply={!!replyTo} - canRemoveQuote={!initQuote} - onSelectVideo={asset => selectVideo(activePost.id, asset)} - onClearVideo={() => clearVideo(activePost.id)} - onPublish={() => onPressPublish(false)} - onError={setError} - /> + {thread.posts.map((post, index) => ( + <React.Fragment key={post.id}> + <ComposerPost + post={post} + dispatch={composerDispatch} + textInput={post.id === activePost.id ? textInput : null} + isFirstPost={index === 0} + isReply={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} + </React.Fragment> + ))} </Animated.ScrollView> - - <React.Fragment key={activePost.id}> - <SuggestedLanguage text={activePost.richtext.text} /> - <ComposerPills - isReply={!!replyTo} - post={activePost} - thread={composerState.thread} - dispatch={composerDispatch} - bottomBarAnimatedStyle={bottomBarAnimatedStyle} - /> - <ComposerFooter - post={activePost} - dispatch={dispatch} - onError={setError} - onEmojiButtonPress={onEmojiButtonPress} - onSelectVideo={asset => selectVideo(activePost.id, asset)} - /> - </React.Fragment> + {!isFooterSticky && footer} </View> <Prompt.Basic @@ -592,11 +637,14 @@ export const ComposePost = ({ ) } -function ComposerPost({ +let ComposerPost = React.memo(function ComposerPost({ post, dispatch, textInput, + isActive, isReply, + isFirstPost, + canRemovePost, canRemoveQuote, onClearVideo, onSelectVideo, @@ -606,10 +654,13 @@ function ComposerPost({ post: PostDraft dispatch: (action: ComposerAction) => void textInput: React.Ref<TextInputRef> + 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 ( - <> + <View style={[styles.post, !isActive && styles.inactivePost]}> <View style={[ styles.textInputLayout, @@ -685,6 +739,12 @@ function ComposerPost({ setRichText={rt => { 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({ /> </View> + {canRemovePost && isActive && ( + <> + <Button + label={_(msg`Delete post`)} + size="small" + color="secondary" + variant="ghost" + shape="round" + style={[a.absolute, {top: 0, right: 0}]} + onPress={() => { + if ( + post.shortenedGraphemeLength > 0 || + post.embed.media || + post.embed.link || + post.embed.quote + ) { + discardPromptControl.open() + } else { + dispatch({ + type: 'remove_post', + postId: post.id, + }) + } + }}> + <ButtonIcon icon={X} /> + </Button> + <Prompt.Basic + control={discardPromptControl} + title={_(msg`Discard post?`)} + description={_(msg`Are you sure you'd like to discard this post?`)} + onConfirm={() => { + dispatch({ + type: 'remove_post', + postId: post.id, + }) + }} + confirmButtonCta={_(msg`Discard`)} + confirmButtonColor="negative" + /> + </> + )} + <ComposerEmbeds canRemoveQuote={canRemoveQuote} embed={post.embed} dispatch={dispatchPost} - clearVideo={onClearVideo} + clearVideo={() => onClearVideo(post.id)} + isActivePost={isActive} /> - </> + </View> ) -} +}) 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<ViewStyle> @@ -769,6 +874,8 @@ function ComposerTopBar({ <ButtonText style={[a.text_md]}> {isReply ? ( <Trans context="action">Reply</Trans> + ) : isThread ? ( + <Trans context="action">Post All</Trans> ) : ( <Trans context="action">Post</Trans> )} @@ -781,7 +888,7 @@ function ComposerTopBar({ ) } -function AltTextReminder() { +function AltTextReminder({error}: {error: string}) { const pal = usePalette('default') return ( <View style={[styles.reminderLine, pal.viewLight]}> @@ -792,9 +899,7 @@ function AltTextReminder() { size={10} /> </View> - <Text style={[pal.text, a.flex_1]}> - <Trans>One or more images is missing alt text.</Trans> - </Text> + <Text style={[pal.text, a.flex_1]}>{error}</Text> </View> ) } @@ -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({ <VideoPreview asset={video.asset} video={video.video} + isActivePost={isActivePost} setDimensions={(width: number, height: number) => { 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} /> <SelectVideoBtn - onSelectVideo={onSelectVideo} + onSelectVideo={asset => onSelectVideo(post.id, asset)} disabled={!!media} setError={onError} /> @@ -1072,6 +1184,21 @@ function ComposerFooter({ )} </View> <View style={[a.flex_row, a.align_center, a.justify_between]}> + {showAddButton && ( + <Button + label={_(msg`Add new post`)} + onPress={onAddPost} + style={[a.p_sm, a.m_2xs]} + variant="ghost" + shape="round" + color="primary"> + <FontAwesomeIcon + icon="add" + size={20} + color={t.palette.primary_500} + /> + </Button> + )} <SelectLangBtn /> <CharProgress count={post.shortenedGraphemeLength} @@ -1179,6 +1306,7 @@ function useAnimatedBorders() { }) return { + contentHeight, scrollHandler, onScrollViewContentSizeChange, onScrollViewLayout, @@ -1217,6 +1345,15 @@ async function whenAppViewReady( ) } +function isEmptyPost(post: PostDraft) { + return ( + post.richtext.text.trim().length === 0 && + !post.embed.media && + !post.embed.link && + !post.embed.quote + ) +} + const styles = StyleSheet.create({ topbarInner: { flexDirection: 'row', @@ -1261,9 +1398,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', marginRight: 5, }, + post: { + marginHorizontal: 16, + }, + inactivePost: { + opacity: 0.5, + }, scrollView: { flex: 1, - paddingHorizontal: 16, }, textInputLayout: { flexDirection: 'row', diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index 5bd5abbc8..cfd2b9065 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -204,6 +204,7 @@ const styles = StyleSheet.create({ paddingTop: 4, paddingBottom: 16, marginBottom: 12, + marginHorizontal: 16, }, replyToPost: { flex: 1, diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index e65c5407a..af784b4f6 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -159,7 +159,10 @@ const GalleryItem = ({ } return ( - <View style={imageStyle}> + <View + style={imageStyle} + // Fixes ALT and icons appearing with half opacity when the post is inactive + renderToHardwareTextureAndroid> <TouchableOpacity testID="altTextButton" accessibilityRole="button" diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 695c84950..94dbc35c6 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -7,6 +7,7 @@ import { import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' @@ -102,6 +103,7 @@ export function SelectLangBtn() { items={items} openUpwards style={styles.button} + hitSlop={LANG_DROPDOWN_HITSLOP} accessibilityLabel={_(msg`Language selection`)} accessibilityHint=""> {postLanguagesPref.length > 0 ? ( @@ -121,7 +123,7 @@ export function SelectLangBtn() { const styles = StyleSheet.create({ button: { - paddingHorizontal: 15, + marginHorizontal: 15, }, label: { maxWidth: 100, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 4c94d5eac..27bed6d44 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -85,7 +85,9 @@ export type ThreadDraft = { export type ComposerState = { thread: ThreadDraft - activePostIndex: number // TODO: Add actions to update this. + activePostIndex: number + mutableNeedsFocusActive: boolean + mutableNeedsScrollToBottom: boolean } export type ComposerAction = @@ -96,6 +98,17 @@ export type ComposerAction = postId: string postAction: PostAction } + | { + type: 'add_post' + } + | { + type: 'remove_post' + postId: string + } + | { + type: 'focus_post' + postId: string + } export const MAX_IMAGES = 4 @@ -142,6 +155,69 @@ export function composerReducer( }, } } + case 'add_post': { + const activePostIndex = state.activePostIndex + const isAtTheEnd = activePostIndex === state.thread.posts.length - 1 + const nextPosts = [...state.thread.posts] + nextPosts.splice(activePostIndex + 1, 0, { + id: nanoid(), + richtext: new RichText({text: ''}), + shortenedGraphemeLength: 0, + labels: [], + embed: { + quote: undefined, + media: undefined, + link: undefined, + }, + }) + return { + ...state, + mutableNeedsScrollToBottom: isAtTheEnd, + thread: { + ...state.thread, + posts: nextPosts, + }, + } + } + case 'remove_post': { + if (state.thread.posts.length < 2) { + return state + } + let nextActivePostIndex = state.activePostIndex + const indexToRemove = state.thread.posts.findIndex( + p => p.id === action.postId, + ) + let nextPosts = [...state.thread.posts] + if (indexToRemove !== -1) { + const postToRemove = state.thread.posts[indexToRemove] + if (postToRemove.embed.media?.type === 'video') { + postToRemove.embed.media.video.abortController.abort() + } + nextPosts.splice(indexToRemove, 1) + nextActivePostIndex = Math.max(0, indexToRemove - 1) + } + return { + ...state, + activePostIndex: nextActivePostIndex, + mutableNeedsFocusActive: true, + thread: { + ...state.thread, + posts: nextPosts, + }, + } + } + case 'focus_post': { + const nextActivePostIndex = state.thread.posts.findIndex( + p => p.id === action.postId, + ) + if (nextActivePostIndex === -1) { + return state + } + return { + ...state, + activePostIndex: nextActivePostIndex, + } + } } } @@ -275,6 +351,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { const prevMedia = state.embed.media let nextMedia = prevMedia if (prevMedia?.type === 'video') { + prevMedia.video.abortController.abort() nextMedia = undefined } let nextLabels = state.labels @@ -436,6 +513,8 @@ export function createComposerState({ }) return { activePostIndex: 0, + mutableNeedsFocusActive: false, + mutableNeedsScrollToBottom: false, thread: { posts: [ { diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 9da220c1b..f4cb004ed 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -47,6 +47,7 @@ interface TextInputProps { onPressPublish: (richtext: RichText) => void onNewLink: (uri: string) => void onError: (err: string) => void + onFocus: () => void } export const TextInput = React.forwardRef(function TextInputImpl( @@ -58,6 +59,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( onPhotoPasted, onPressPublish, onNewLink, + onFocus, }: // onError, TODO TextInputProps, ref, @@ -149,6 +151,9 @@ export const TextInput = React.forwardRef(function TextInputImpl( const editor = useEditor( { extensions, + onFocus() { + onFocus?.() + }, editorProps: { attributes: { class: modeClass, @@ -244,8 +249,12 @@ export const TextInput = React.forwardRef(function TextInputImpl( }, [onEmojiInserted]) React.useImperativeHandle(ref, () => ({ - focus: () => {}, // TODO - blur: () => {}, // TODO + focus: () => { + editor?.chain().focus() + }, + blur: () => { + editor?.chain().blur() + }, getCursorPosition: () => { const pos = editor?.state.selection.$anchor.pos return pos ? editor?.view.coordsAtPos(pos) : undefined diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 50a38f976..fff7545a5 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -9,14 +9,17 @@ import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' import {atoms as a, useTheme} from '#/alf' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' export function VideoPreview({ asset, video, clear, + isActivePost, }: { asset: ImagePickerAsset video: CompressedVideo + isActivePost: boolean setDimensions: (width: number, height: number) => void clear: () => void }) { @@ -42,13 +45,18 @@ export function VideoPreview({ t.atoms.border_contrast_low, {backgroundColor: 'black'}, ]}> - <BlueskyVideoView - url={video.uri} - autoplay={!autoplayDisabled} - beginMuted={true} - forceTakeover={true} - ref={playerRef} - /> + <View style={[a.absolute, a.inset_0]}> + <VideoTranscodeBackdrop uri={asset.uri} /> + </View> + {isActivePost && ( + <BlueskyVideoView + url={video.uri} + autoplay={!autoplayDisabled} + beginMuted={true} + forceTakeover={true} + ref={playerRef} + /> + )} <ExternalEmbedRemoveBtn onRemove={clear} /> {autoplayDisabled && ( <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index a989cd5c5..f0751e45b 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -2,6 +2,7 @@ import React, {PropsWithChildren, useMemo, useRef} from 'react' import { Dimensions, GestureResponderEvent, + Insets, StyleProp, StyleSheet, TouchableOpacity, @@ -64,6 +65,7 @@ interface DropdownButtonProps { openUpwards?: boolean rightOffset?: number bottomOffset?: number + hitSlop?: Insets accessibilityLabel?: string accessibilityHint?: string } @@ -80,6 +82,7 @@ export function DropdownButton({ openUpwards = false, rightOffset = 0, bottomOffset = 0, + hitSlop = HITSLOP_10, accessibilityLabel, }: PropsWithChildren<DropdownButtonProps>) { const {_} = useLingui() @@ -152,7 +155,7 @@ export function DropdownButton({ testID={testID} style={style} onPress={onPress} - hitSlop={HITSLOP_10} + hitSlop={hitSlop} ref={ref1} accessibilityRole="button" accessibilityLabel={ diff --git a/yarn.lock b/yarn.lock index 09c837238..4a3b94095 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4114,10 +4114,10 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== -"@haileyok/bluesky-video@0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.3.tgz#096fc65d49b4811f79ecb83c39b44409902f4f3e" - integrity sha512-1j1t/o2zrrh09LaZGzfoXQu+LuigJ1+HxQ30jq0naD2FEfQF1zLz6zOtDyywDSR8SUl9O0KsZxBi+wvQW1NgbA== +"@haileyok/bluesky-video@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.4.tgz#1591cbf744640e0cdac2a4bcaec22df916ce34b5" + integrity sha512-Vm2rdZYPUwD8Ncxqyy+YlBVhtJFkLhutGyoZayZJ5ATV/S8msbN4RK8MgXLvzlJfW497cLbk1w6M8kPU1xoDEQ== "@hapi/accept@^6.0.3": version "6.0.3" |