diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 866 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 10 |
3 files changed, 521 insertions, 357 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 249ba99e5..a523c3f52 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -42,6 +42,7 @@ import { AppBskyFeedDefs, AppBskyFeedGetPostThread, BskyAgent, + RichText, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -112,8 +113,11 @@ import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { + ComposerAction, + ComposerDraft, composerReducer, createComposerState, + EmbedDraft, MAX_IMAGES, } from './state/composer' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' @@ -142,10 +146,7 @@ export const ComposePost = ({ const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did - const {data: currentProfile} = useProfileQuery({did: currentDid}) const {closeComposer} = useComposerControls() - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() const {_} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() @@ -154,11 +155,10 @@ export const ComposePost = ({ const discardPromptControl = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() - const t = useTheme() const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) - const [isProcessing, setIsProcessing] = useState(false) - const [processingState, setProcessingState] = useState('') + const [isPublishing, setIsPublishing] = useState(false) + const [publishingStage, setPublishingStage] = useState('') const [error, setError] = useState('') const [draft, dispatch] = useReducer( @@ -222,22 +222,6 @@ export const ComposePost = ({ dispatch({type: 'embed_remove_video'}) }, [videoState.abortController, dispatch]) - const updateVideoDimensions = useCallback( - (width: number, height: number) => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_dimensions', - width, - height, - signal: videoState.abortController.signal, - }, - }) - }, - [videoState.abortController], - ) - - const hasVideo = Boolean(videoState.asset || videoState.video) const [publishOnUpload, setPublishOnUpload] = useState(false) const onClose = useCallback(() => { @@ -299,32 +283,6 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) - const onNewLink = useCallback((uri: string) => { - dispatch({type: 'embed_add_uri', uri}) - }, []) - - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatch({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatch], - ) - - const onPhotoPasted = useCallback( - async (uri: string) => { - if (uri.startsWith('data:video/')) { - selectVideo({uri, type: 'video', height: 0, width: 0}) - } else { - const res = await pasteImage(uri) - onImageAdd([res]) - } - }, - [selectVideo, onImageAdd], - ) - const isAltTextRequiredAndMissing = useMemo(() => { if (!requireAltTextEnabled) return false @@ -336,8 +294,8 @@ export const ComposePost = ({ }, [images, extGifAlt, extGif, requireAltTextEnabled]) const onPressPublish = React.useCallback( - async (finishedUploading?: boolean) => { - if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { + async (finishedUploading: boolean) => { + if (isPublishing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } @@ -368,7 +326,7 @@ export const ComposePost = ({ return } - setIsProcessing(true) + setIsPublishing(true) let postUri try { @@ -376,7 +334,7 @@ export const ComposePost = ({ await apilib.post(agent, queryClient, { draft: draft, replyTo: replyTo?.uri, - onStateChange: setProcessingState, + onStateChange: setPublishingStage, langs: toPostLanguages(langPrefs.postLanguage), }) ).uri @@ -406,7 +364,7 @@ export const ComposePost = ({ err = _(msg`This post's author has disabled quote posts.`) } setError(err) - setIsProcessing(false) + setIsPublishing(false) return } finally { if (postUri) { @@ -456,7 +414,7 @@ export const ComposePost = ({ images, graphemeLength, isAltTextRequiredAndMissing, - isProcessing, + isPublishing, langPrefs.postLanguage, onClose, onPost, @@ -484,28 +442,11 @@ export const ComposePost = ({ () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, [graphemeLength, isAltTextRequiredAndMissing], ) - const selectTextInputPlaceholder = replyTo - ? _(msg`Write your reply`) - : _(msg`What's up?`) - - const canSelectImages = - images.length < MAX_IMAGES && - videoState.status === 'idle' && - !videoState.video - const hasMedia = images.length > 0 || Boolean(videoState.video) const onEmojiButtonPress = useCallback(() => { openEmojiPicker?.(textInput.current?.getCursorPosition()) }, [openEmojiPicker]) - const onSelectGif = useCallback((gif: Gif) => { - dispatch({type: 'embed_add_gif', gif}) - }, []) - - const handleChangeGifAltText = useCallback((altText: string) => { - dispatch({type: 'embed_update_gif', alt: altText}) - }, []) - const { scrollHandler, onScrollViewContentSizeChange, @@ -527,86 +468,24 @@ export const ComposePost = ({ style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal> - <Animated.View - style={topBarAnimatedStyle} - layout={native(LinearTransition)}> - <View style={styles.topbarInner}> - <Button - label={_(msg`Cancel`)} - variant="ghost" - color="primary" - shape="default" - size="small" - style={[ - a.rounded_full, - a.py_sm, - {paddingLeft: 7, paddingRight: 7}, - ]} - onPress={onPressCancel} - accessibilityHint={_( - msg`Closes post composer and discards post draft`, - )}> - <ButtonText style={[a.text_md]}> - <Trans>Cancel</Trans> - </ButtonText> - </Button> - <View style={a.flex_1} /> - {isProcessing ? ( - <> - <Text style={pal.textLight}>{processingState}</Text> - <View style={styles.postBtn}> - <ActivityIndicator /> - </View> - </> - ) : canPost ? ( - <Button - testID="composerPublishBtn" - label={replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)} - variant="solid" - color="primary" - shape="default" - size="small" - style={[a.rounded_full, a.py_sm]} - onPress={() => onPressPublish()} - disabled={videoState.status !== 'idle' && publishOnUpload}> - <ButtonText style={[a.text_md]}> - {replyTo ? ( - <Trans context="action">Reply</Trans> - ) : ( - <Trans context="action">Post</Trans> - )} - </ButtonText> - </Button> - ) : ( - <View style={[styles.postBtn, pal.btn]}> - <Text style={[pal.textLight, s.f16, s.bold]}> - <Trans context="action">Post</Trans> - </Text> - </View> - )} - </View> - - {isAltTextRequiredAndMissing && ( - <View style={[styles.reminderLine, pal.viewLight]}> - <View style={styles.errorIcon}> - <FontAwesomeIcon - icon="exclamation" - style={{color: colors.red4}} - size={10} - /> - </View> - <Text style={[pal.text, a.flex_1]}> - <Trans>One or more images is missing alt text.</Trans> - </Text> - </View> - )} + <ComposerTopBar + canPost={canPost} + isReply={!!replyTo} + isPublishQueued={videoState.status !== 'idle' && publishOnUpload} + isPublishing={isPublishing} + publishingStage={publishingStage} + topBarAnimatedStyle={topBarAnimatedStyle} + onCancel={onPressCancel} + onPublish={() => onPressPublish(false)}> + {isAltTextRequiredAndMissing && <AltTextReminder />} <ErrorBanner error={error} videoState={videoState} clearError={() => setError('')} clearVideo={clearVideo} /> - </Animated.View> + </ComposerTopBar> + <Animated.ScrollView layout={native(LinearTransition)} onScroll={scrollHandler} @@ -615,228 +494,513 @@ export const ComposePost = ({ onContentSizeChange={onScrollViewContentSizeChange} onLayout={onScrollViewLayout}> {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} + <ComposerPost + draft={draft} + dispatch={dispatch} + textInput={textInput} + isReply={!!replyTo} + canRemoveQuote={!initQuote} + onSelectVideo={selectVideo} + onClearVideo={clearVideo} + onPublish={() => onPressPublish(false)} + onError={setError} + /> + </Animated.ScrollView> - <View - style={[ - styles.textInputLayout, - isNative && styles.textInputLayoutMobile, - ]}> - <UserAvatar - avatar={currentProfile?.avatar} - size={50} - type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} - /> - <TextInput - ref={textInput} - richtext={richtext} - placeholder={selectTextInputPlaceholder} - autoFocus - setRichText={rt => { - dispatch({type: 'update_richtext', richtext: rt}) - }} - onPhotoPasted={onPhotoPasted} - onPressPublish={() => onPressPublish()} - onNewLink={onNewLink} - onError={setError} - accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, - )} - /> - </View> + <SuggestedLanguage text={richtext.text} /> - <Gallery images={images} dispatch={dispatch} /> + <ComposerPills + isReply={!!replyTo} + draft={draft} + dispatch={dispatch} + bottomBarAnimatedStyle={bottomBarAnimatedStyle} + /> + + <ComposerFooter + draft={draft} + graphemeLength={graphemeLength} + dispatch={dispatch} + onError={setError} + onEmojiButtonPress={onEmojiButtonPress} + onSelectVideo={selectVideo} + /> + </View> - {extGif && ( - <View style={a.relative} key={extGif.url}> - <ExternalEmbedGif - gif={extGif} - onRemove={() => { - dispatch({type: 'embed_remove_gif'}) - }} - /> - <GifAltTextDialog - gif={extGif} - altText={extGifAlt ?? ''} - onSubmit={handleChangeGifAltText} - /> - </View> - )} + <Prompt.Basic + control={discardPromptControl} + title={_(msg`Discard draft?`)} + description={_(msg`Are you sure you'd like to discard this draft?`)} + onConfirm={onClose} + confirmButtonCta={_(msg`Discard`)} + confirmButtonColor="negative" + /> + </KeyboardAvoidingView> + </BottomSheetPortalProvider> + ) +} - {!draft.embed.media && extLink && ( - <View style={a.relative} key={extLink}> - <ExternalEmbedLink - uri={extLink} - hasQuote={!!quote} - onRemove={() => { - dispatch({type: 'embed_remove_link'}) - }} - /> - </View> - )} +function ComposerPost({ + draft, + dispatch, + textInput, + isReply, + canRemoveQuote, + onClearVideo, + onSelectVideo, + onError, + onPublish, +}: { + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + textInput: React.Ref<TextInputRef> + isReply: boolean + canRemoveQuote: boolean + onClearVideo: () => void + onSelectVideo: (asset: ImagePickerAsset) => void + onError: (error: string) => void + onPublish: (richtext: RichText) => void +}) { + const {currentAccount} = useSession() + const currentDid = currentAccount!.did + const {_} = useLingui() + const {data: currentProfile} = useProfileQuery({did: currentDid}) + const richtext = draft.richtext + const selectTextInputPlaceholder = isReply + ? _(msg`Write your reply`) + : _(msg`What's up?`) - <LayoutAnimationConfig skipExiting> - {hasVideo && ( - <Animated.View - style={[a.w_full, a.mt_lg]} - entering={native(ZoomIn)} - exiting={native(ZoomOut)}> - {videoState.asset && - (videoState.status === 'compressing' ? ( - <VideoTranscodeProgress - asset={videoState.asset} - progress={videoState.progress} - clear={clearVideo} - /> - ) : videoState.video ? ( - <VideoPreview - asset={videoState.asset} - video={videoState.video} - setDimensions={updateVideoDimensions} - clear={clearVideo} - /> - ) : null)} - <SubtitleDialogBtn - defaultAltText={videoState.altText} - saveAltText={altText => - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_alt_text', - altText, - signal: videoState.abortController.signal, - }, - }) - } - captions={videoState.captions} - setCaptions={updater => { - dispatch({ - type: 'embed_update_video', - videoAction: { - type: 'update_captions', - updater, - signal: videoState.abortController.signal, - }, - }) - }} - /> - </Animated.View> - )} - </LayoutAnimationConfig> - <View style={!hasVideo ? [a.mt_md] : []}> - {quote ? ( - <View style={[s.mt5, s.mb2, isWeb && s.mb10]}> - <View style={{pointerEvents: 'none'}}> - <LazyQuoteEmbed uri={quote} /> - </View> - {!initQuote && ( - <QuoteX - onRemove={() => { - dispatch({type: 'embed_remove_quote'}) - }} - /> - )} - </View> - ) : null} + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + dispatch({ + type: 'embed_add_images', + images: next, + }) + }, + [dispatch], + ) + + const onNewLink = useCallback( + (uri: string) => { + dispatch({type: 'embed_add_uri', uri}) + }, + [dispatch], + ) + + const onPhotoPasted = useCallback( + async (uri: string) => { + if (uri.startsWith('data:video/')) { + onSelectVideo({uri, type: 'video', height: 0, width: 0}) + } else { + const res = await pasteImage(uri) + onImageAdd([res]) + } + }, + [onSelectVideo, onImageAdd], + ) + + return ( + <> + <View + style={[ + styles.textInputLayout, + isNative && styles.textInputLayoutMobile, + ]}> + <UserAvatar + avatar={currentProfile?.avatar} + size={50} + type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} + /> + <TextInput + ref={textInput} + richtext={richtext} + placeholder={selectTextInputPlaceholder} + autoFocus + setRichText={rt => { + dispatch({type: 'update_richtext', richtext: rt}) + }} + onPhotoPasted={onPhotoPasted} + onNewLink={onNewLink} + onError={onError} + onPressPublish={onPublish} + accessible={true} + accessibilityLabel={_(msg`Write post`)} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} + /> + </View> + + <ComposerEmbeds + canRemoveQuote={canRemoveQuote} + embed={draft.embed} + dispatch={dispatch} + clearVideo={onClearVideo} + /> + </> + ) +} + +function ComposerTopBar({ + canPost, + isReply, + isPublishQueued, + isPublishing, + publishingStage, + onCancel, + onPublish, + topBarAnimatedStyle, + children, +}: { + isPublishing: boolean + publishingStage: string + canPost: boolean + isReply: boolean + isPublishQueued: boolean + onCancel: () => void + onPublish: () => void + topBarAnimatedStyle: StyleProp<ViewStyle> + children?: React.ReactNode +}) { + const pal = usePalette('default') + return ( + <Animated.View + style={topBarAnimatedStyle} + layout={native(LinearTransition)}> + <View style={styles.topbarInner}> + <Button + label="Cancel" + variant="ghost" + color="primary" + shape="default" + size="small" + style={[a.rounded_full, a.py_sm, {paddingLeft: 7, paddingRight: 7}]} + onPress={onCancel} + accessibilityHint="Closes post composer and discards post draft"> + <ButtonText style={[a.text_md]}> + <Trans>Cancel</Trans> + </ButtonText> + </Button> + <View style={a.flex_1} /> + {isPublishing ? ( + <> + <Text>{publishingStage}</Text> + <View style={styles.postBtn}> + <ActivityIndicator /> </View> - </Animated.ScrollView> - <SuggestedLanguage text={richtext.text} /> + </> + ) : canPost ? ( + <Button + testID="composerPublishBtn" + label={isReply ? 'Publish reply' : 'Publish post'} + variant="solid" + color="primary" + shape="default" + size="small" + style={[a.rounded_full, a.py_sm]} + onPress={onPublish} + disabled={isPublishQueued}> + <ButtonText style={[a.text_md]}> + {isReply ? ( + <Trans context="action">Reply</Trans> + ) : ( + <Trans context="action">Post</Trans> + )} + </ButtonText> + </Button> + ) : ( + <View style={[styles.postBtn, pal.btn]}> + <Text style={[pal.textLight, s.f16, s.bold]}> + <Trans context="action">Post</Trans> + </Text> + </View> + )} + </View> + {children} + </Animated.View> + ) +} + +function AltTextReminder() { + const pal = usePalette('default') + return ( + <View style={[styles.reminderLine, pal.viewLight]}> + <View style={styles.errorIcon}> + <FontAwesomeIcon + icon="exclamation" + style={{color: colors.red4}} + size={10} + /> + </View> + <Text style={[pal.text, a.flex_1]}> + <Trans>One or more images is missing alt text.</Trans> + </Text> + </View> + ) +} + +function ComposerEmbeds({ + embed, + dispatch, + clearVideo, + canRemoveQuote, +}: { + embed: EmbedDraft + dispatch: (action: ComposerAction) => void + clearVideo: () => void + canRemoveQuote: boolean +}) { + const video = embed.media?.type === 'video' ? embed.media.video : null + return ( + <> + {embed.media?.type === 'images' && ( + <Gallery images={embed.media.images} dispatch={dispatch} /> + )} + + {embed.media?.type === 'gif' && ( + <View style={a.relative} key={embed.media.gif.url}> + <ExternalEmbedGif + gif={embed.media.gif} + onRemove={() => dispatch({type: 'embed_remove_gif'})} + /> + <GifAltTextDialog + gif={embed.media.gif} + altText={embed.media.alt ?? ''} + onSubmit={(altText: string) => { + dispatch({type: 'embed_update_gif', alt: altText}) + }} + /> + </View> + )} + + {!embed.media && embed.link && ( + <View style={a.relative} key={embed.link.uri}> + <ExternalEmbedLink + uri={embed.link.uri} + hasQuote={!!embed.quote} + onRemove={() => dispatch({type: 'embed_remove_link'})} + /> + </View> + )} + <LayoutAnimationConfig skipExiting> + {video && ( <Animated.View - style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}> - <ScrollView - contentContainerStyle={[a.gap_sm]} - horizontal={true} - bounces={false} - showsHorizontalScrollIndicator={false}> - {replyTo ? null : ( - <ThreadgateBtn - postgate={draft.postgate} - onChangePostgate={nextPostgate => { - dispatch({type: 'update_postgate', postgate: nextPostgate}) - }} - threadgateAllowUISettings={draft.threadgate} - onChangeThreadgateAllowUISettings={nextThreadgate => { + style={[a.w_full, a.mt_lg]} + entering={native(ZoomIn)} + exiting={native(ZoomOut)}> + {video.asset && + (video.status === 'compressing' ? ( + <VideoTranscodeProgress + asset={video.asset} + progress={video.progress} + clear={clearVideo} + /> + ) : video.video ? ( + <VideoPreview + asset={video.asset} + video={video.video} + setDimensions={(width: number, height: number) => { dispatch({ - type: 'update_threadgate', - threadgate: nextThreadgate, + type: 'embed_update_video', + videoAction: { + type: 'update_dimensions', + width, + height, + signal: video.abortController.signal, + }, }) }} - style={bottomBarAnimatedStyle} + clear={clearVideo} /> - )} - <LabelsBtn - labels={draft.labels} - onChange={nextLabels => { - dispatch({type: 'update_labels', labels: nextLabels}) - }} - hasMedia={hasMedia || Boolean(extLink)} - /> - </ScrollView> + ) : null)} + <SubtitleDialogBtn + defaultAltText={video.altText} + saveAltText={altText => + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText, + signal: video.abortController.signal, + }, + }) + } + captions={video.captions} + setCaptions={updater => { + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_captions', + updater, + signal: video.abortController.signal, + }, + }) + }} + /> </Animated.View> - <View - style={[ - a.flex_row, - a.py_xs, - {paddingLeft: 7, paddingRight: 16}, - a.align_center, - a.border_t, - t.atoms.bg, - t.atoms.border_contrast_medium, - a.justify_between, - ]}> - <View style={[a.flex_row, a.align_center]}> - {videoState.status !== 'idle' && videoState.status !== 'done' ? ( - <VideoUploadToolbar state={videoState} /> - ) : ( - <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> - <SelectPhotoBtn - size={images.length} - disabled={!canSelectImages} - onAdd={onImageAdd} - /> - <SelectVideoBtn - onSelectVideo={selectVideo} - disabled={!canSelectImages || images?.length > 0} - setError={setError} - /> - <OpenCameraBtn - disabled={!canSelectImages} - onAdd={onImageAdd} - /> - <SelectGifBtn onSelectGif={onSelectGif} disabled={hasMedia} /> - {!isMobile ? ( - <Button - onPress={onEmojiButtonPress} - style={a.p_sm} - label={_(msg`Open emoji picker`)} - accessibilityHint={_(msg`Open emoji picker`)} - variant="ghost" - shape="round" - color="primary"> - <EmojiSmile size="lg" /> - </Button> - ) : null} - </ToolbarWrapper> - )} - </View> - <View style={[a.flex_row, a.align_center, a.justify_between]}> - <SelectLangBtn /> - <CharProgress count={graphemeLength} style={{width: 65}} /> + )} + </LayoutAnimationConfig> + + <View style={!video ? [a.mt_md] : []}> + {embed.quote?.uri ? ( + <View style={[s.mt5, s.mb2, isWeb && s.mb10]}> + <View style={{pointerEvents: 'none'}}> + <LazyQuoteEmbed uri={embed.quote.uri} /> </View> + {canRemoveQuote && ( + <QuoteX onRemove={() => dispatch({type: 'embed_remove_quote'})} /> + )} </View> - </View> - <Prompt.Basic - control={discardPromptControl} - title={_(msg`Discard draft?`)} - description={_(msg`Are you sure you'd like to discard this draft?`)} - onConfirm={onClose} - confirmButtonCta={_(msg`Discard`)} - confirmButtonColor="negative" + ) : null} + </View> + </> + ) +} + +function ComposerPills({ + isReply, + draft, + dispatch, + bottomBarAnimatedStyle, +}: { + isReply: boolean + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + bottomBarAnimatedStyle: StyleProp<ViewStyle> +}) { + const t = useTheme() + const media = draft.embed.media + const hasMedia = media?.type === 'images' || media?.type === 'video' + const hasLink = !!draft.embed.link + return ( + <Animated.View + style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}> + <ScrollView + contentContainerStyle={[a.gap_sm]} + horizontal={true} + bounces={false} + showsHorizontalScrollIndicator={false}> + {isReply ? null : ( + <ThreadgateBtn + postgate={draft.postgate} + onChangePostgate={nextPostgate => { + dispatch({type: 'update_postgate', postgate: nextPostgate}) + }} + threadgateAllowUISettings={draft.threadgate} + onChangeThreadgateAllowUISettings={nextThreadgate => { + dispatch({ + type: 'update_threadgate', + threadgate: nextThreadgate, + }) + }} + style={bottomBarAnimatedStyle} + /> + )} + <LabelsBtn + labels={draft.labels} + onChange={nextLabels => { + dispatch({type: 'update_labels', labels: nextLabels}) + }} + hasMedia={hasMedia || hasLink} /> - </KeyboardAvoidingView> - </BottomSheetPortalProvider> + </ScrollView> + </Animated.View> + ) +} + +function ComposerFooter({ + draft, + dispatch, + graphemeLength, + onEmojiButtonPress, + onError, + onSelectVideo, +}: { + draft: ComposerDraft + dispatch: (action: ComposerAction) => void + graphemeLength: number + onEmojiButtonPress: () => void + onError: (error: string) => void + onSelectVideo: (asset: ImagePickerAsset) => void +}) { + const t = useTheme() + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + + const media = draft.embed.media + const images = media?.type === 'images' ? media.images : [] + const video = media?.type === 'video' ? media.video : null + const isMaxImages = images.length >= MAX_IMAGES + + const onImageAdd = useCallback( + (next: ComposerImage[]) => { + dispatch({ + type: 'embed_add_images', + images: next, + }) + }, + [dispatch], + ) + + const onSelectGif = useCallback( + (gif: Gif) => { + dispatch({type: 'embed_add_gif', gif}) + }, + [dispatch], + ) + + return ( + <View + style={[ + a.flex_row, + a.py_xs, + {paddingLeft: 7, paddingRight: 16}, + a.align_center, + a.border_t, + t.atoms.bg, + t.atoms.border_contrast_medium, + a.justify_between, + ]}> + <View style={[a.flex_row, a.align_center]}> + {video && video.status !== 'done' ? ( + <VideoUploadToolbar state={video} /> + ) : ( + <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> + <SelectPhotoBtn + size={images.length} + disabled={media?.type === 'images' ? isMaxImages : !!media} + onAdd={onImageAdd} + /> + <SelectVideoBtn + onSelectVideo={onSelectVideo} + disabled={!!media} + setError={onError} + /> + <OpenCameraBtn + disabled={media?.type === 'images' ? isMaxImages : !!media} + onAdd={onImageAdd} + /> + <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> + {!isMobile ? ( + <Button + onPress={onEmojiButtonPress} + style={a.p_sm} + label={_(msg`Open emoji picker`)} + accessibilityHint={_(msg`Open emoji picker`)} + variant="ghost" + shape="round" + color="primary"> + <EmojiSmile size="lg" /> + </Button> + ) : null} + </ToolbarWrapper> + )} + </View> + <View style={[a.flex_row, a.align_center, a.justify_between]}> + <SelectLangBtn /> + <CharProgress count={graphemeLength} style={{width: 65}} /> + </View> + </View> ) } diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 43074fa5b..11bbf13d2 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -45,7 +45,7 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> { placeholder: string setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise<void> + onPressPublish: (richtext: RichText) => void onNewLink: (uri: string) => void onError: (err: string) => void } diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index acec61516..1d7908e16 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -13,15 +13,15 @@ import {Text as TiptapText} from '@tiptap/extension-text' import {generateJSON} from '@tiptap/html' import {EditorContent, JSONContent, useEditor} from '@tiptap/react' +import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {usePalette} from '#/lib/hooks/usePalette' +import {blobToDataUri, isUriImage} from '#/lib/media/util' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {blobToDataUri, isUriImage} from 'lib/media/util' -import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import { LinkFacetMatch, suggestLinkCardUri, -} from 'view/com/composer/text-input/text-input-util' +} from '#/view/com/composer/text-input/text-input-util' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a, useAlf} from '#/alf' import {Portal} from '#/components/Portal' import {normalizeTextStyles} from '#/components/Typography' @@ -43,7 +43,7 @@ interface TextInputProps { suggestedLinks: Set<string> setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => Promise<void> + onPressPublish: (richtext: RichText) => void onNewLink: (uri: string) => void onError: (err: string) => void } |