Split composer into smaller components (#5941)

* Extract ComposerTopBar

* Rename state variables to align with props

* Extract ComposerEmbeds

* Extract ComposerPills

* Extract ComposerFooter

* Tweak condition to be simpler

* Extract ComposerPost
This commit is contained in:
dan
2024-10-25 21:36:54 +01:00
committed by GitHub
parent a729efc3b6
commit d520dd95b9
3 changed files with 523 additions and 359 deletions
+517 -353
View File
@@ -42,6 +42,7 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedGetPostThread,
BskyAgent, BskyAgent,
RichText,
} from '@atproto/api' } from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -112,8 +113,11 @@ import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography' import {Text as NewText} from '#/components/Typography'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import { import {
ComposerAction,
ComposerDraft,
composerReducer, composerReducer,
createComposerState, createComposerState,
EmbedDraft,
MAX_IMAGES, MAX_IMAGES,
} from './state/composer' } from './state/composer'
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
@@ -142,10 +146,7 @@ export const ComposePost = ({
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const currentDid = currentAccount!.did const currentDid = currentAccount!.did
const {data: currentProfile} = useProfileQuery({did: currentDid})
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const {_} = useLingui() const {_} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled() const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
@@ -154,11 +155,10 @@ export const ComposePost = ({
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext() const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls() const {closeAllModals} = useModalControls()
const t = useTheme()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isProcessing, setIsProcessing] = useState(false) const [isPublishing, setIsPublishing] = useState(false)
const [processingState, setProcessingState] = useState('') const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [draft, dispatch] = useReducer( const [draft, dispatch] = useReducer(
@@ -222,22 +222,6 @@ export const ComposePost = ({
dispatch({type: 'embed_remove_video'}) dispatch({type: 'embed_remove_video'})
}, [videoState.abortController, dispatch]) }, [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 [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => { const onClose = useCallback(() => {
@@ -299,32 +283,6 @@ export const ComposePost = ({
} }
}, [onPressCancel, closeAllDialogs, closeAllModals]) }, [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(() => { const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false if (!requireAltTextEnabled) return false
@@ -336,8 +294,8 @@ export const ComposePost = ({
}, [images, extGifAlt, extGif, requireAltTextEnabled]) }, [images, extGifAlt, extGif, requireAltTextEnabled])
const onPressPublish = React.useCallback( const onPressPublish = React.useCallback(
async (finishedUploading?: boolean) => { async (finishedUploading: boolean) => {
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { if (isPublishing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return return
} }
@@ -368,7 +326,7 @@ export const ComposePost = ({
return return
} }
setIsProcessing(true) setIsPublishing(true)
let postUri let postUri
try { try {
@@ -376,7 +334,7 @@ export const ComposePost = ({
await apilib.post(agent, queryClient, { await apilib.post(agent, queryClient, {
draft: draft, draft: draft,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
onStateChange: setProcessingState, onStateChange: setPublishingStage,
langs: toPostLanguages(langPrefs.postLanguage), langs: toPostLanguages(langPrefs.postLanguage),
}) })
).uri ).uri
@@ -406,7 +364,7 @@ export const ComposePost = ({
err = _(msg`This post's author has disabled quote posts.`) err = _(msg`This post's author has disabled quote posts.`)
} }
setError(err) setError(err)
setIsProcessing(false) setIsPublishing(false)
return return
} finally { } finally {
if (postUri) { if (postUri) {
@@ -456,7 +414,7 @@ export const ComposePost = ({
images, images,
graphemeLength, graphemeLength,
isAltTextRequiredAndMissing, isAltTextRequiredAndMissing,
isProcessing, isPublishing,
langPrefs.postLanguage, langPrefs.postLanguage,
onClose, onClose,
onPost, onPost,
@@ -484,28 +442,11 @@ export const ComposePost = ({
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing, () => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
[graphemeLength, 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(() => { const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition()) openEmojiPicker?.(textInput.current?.getCursorPosition())
}, [openEmojiPicker]) }, [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 { const {
scrollHandler, scrollHandler,
onScrollViewContentSizeChange, onScrollViewContentSizeChange,
@@ -527,86 +468,24 @@ export const ComposePost = ({
style={[a.flex_1, viewStyles]} style={[a.flex_1, viewStyles]}
aria-modal aria-modal
accessibilityViewIsModal> accessibilityViewIsModal>
<Animated.View <ComposerTopBar
style={topBarAnimatedStyle} canPost={canPost}
layout={native(LinearTransition)}> isReply={!!replyTo}
<View style={styles.topbarInner}> isPublishQueued={videoState.status !== 'idle' && publishOnUpload}
<Button isPublishing={isPublishing}
label={_(msg`Cancel`)} publishingStage={publishingStage}
variant="ghost" topBarAnimatedStyle={topBarAnimatedStyle}
color="primary" onCancel={onPressCancel}
shape="default" onPublish={() => onPressPublish(false)}>
size="small" {isAltTextRequiredAndMissing && <AltTextReminder />}
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>
)}
<ErrorBanner <ErrorBanner
error={error} error={error}
videoState={videoState} videoState={videoState}
clearError={() => setError('')} clearError={() => setError('')}
clearVideo={clearVideo} clearVideo={clearVideo}
/> />
</Animated.View> </ComposerTopBar>
<Animated.ScrollView <Animated.ScrollView
layout={native(LinearTransition)} layout={native(LinearTransition)}
onScroll={scrollHandler} onScroll={scrollHandler}
@@ -615,218 +494,38 @@ export const ComposePost = ({
onContentSizeChange={onScrollViewContentSizeChange} onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}> onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<ComposerPost
<View draft={draft}
style={[ dispatch={dispatch}
styles.textInputLayout, textInput={textInput}
isNative && styles.textInputLayoutMobile, isReply={!!replyTo}
]}> canRemoveQuote={!initQuote}
<UserAvatar onSelectVideo={selectVideo}
avatar={currentProfile?.avatar} onClearVideo={clearVideo}
size={50} onPublish={() => onPressPublish(false)}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} onError={setError}
/> />
<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>
<Gallery images={images} dispatch={dispatch} />
{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>
)}
{!draft.embed.media && extLink && (
<View style={a.relative} key={extLink}>
<ExternalEmbedLink
uri={extLink}
hasQuote={!!quote}
onRemove={() => {
dispatch({type: 'embed_remove_link'})
}}
/>
</View>
)}
<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}
</View>
</Animated.ScrollView> </Animated.ScrollView>
<SuggestedLanguage text={richtext.text} /> <SuggestedLanguage text={richtext.text} />
<Animated.View <ComposerPills
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}> isReply={!!replyTo}
<ScrollView draft={draft}
contentContainerStyle={[a.gap_sm]} dispatch={dispatch}
horizontal={true} bottomBarAnimatedStyle={bottomBarAnimatedStyle}
bounces={false} />
showsHorizontalScrollIndicator={false}>
{replyTo ? null : ( <ComposerFooter
<ThreadgateBtn draft={draft}
postgate={draft.postgate} graphemeLength={graphemeLength}
onChangePostgate={nextPostgate => { dispatch={dispatch}
dispatch({type: 'update_postgate', postgate: nextPostgate}) onError={setError}
}} onEmojiButtonPress={onEmojiButtonPress}
threadgateAllowUISettings={draft.threadgate} onSelectVideo={selectVideo}
onChangeThreadgateAllowUISettings={nextThreadgate => { />
dispatch({
type: 'update_threadgate',
threadgate: nextThreadgate,
})
}}
style={bottomBarAnimatedStyle}
/>
)}
<LabelsBtn
labels={draft.labels}
onChange={nextLabels => {
dispatch({type: 'update_labels', labels: nextLabels})
}}
hasMedia={hasMedia || Boolean(extLink)}
/>
</ScrollView>
</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}} />
</View>
</View>
</View> </View>
<Prompt.Basic <Prompt.Basic
control={discardPromptControl} control={discardPromptControl}
title={_(msg`Discard draft?`)} title={_(msg`Discard draft?`)}
@@ -840,6 +539,471 @@ export const ComposePost = ({
) )
} }
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?`)
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>
</>
) : 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.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: 'embed_update_video',
videoAction: {
type: 'update_dimensions',
width,
height,
signal: video.abortController.signal,
},
})
}}
clear={clearVideo}
/>
) : 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>
)}
</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>
) : 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}
/>
</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>
)
}
export function useComposerCancelRef() { export function useComposerCancelRef() {
return useRef<CancelRef>(null) return useRef<CancelRef>(null)
} }
@@ -45,7 +45,7 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> {
placeholder: string placeholder: string
setRichText: (v: RichText) => void setRichText: (v: RichText) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void> onPressPublish: (richtext: RichText) => void
onNewLink: (uri: string) => void onNewLink: (uri: string) => void
onError: (err: string) => void onError: (err: string) => void
} }
@@ -13,15 +13,15 @@ import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html' import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react' import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {blobToDataUri, isUriImage} from '#/lib/media/util'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' 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 { import {
LinkFacetMatch, LinkFacetMatch,
suggestLinkCardUri, 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 {atoms as a, useAlf} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {normalizeTextStyles} from '#/components/Typography' import {normalizeTextStyles} from '#/components/Typography'
@@ -43,7 +43,7 @@ interface TextInputProps {
suggestedLinks: Set<string> suggestedLinks: Set<string>
setRichText: (v: RichText | ((v: RichText) => RichText)) => void setRichText: (v: RichText | ((v: RichText) => RichText)) => void
onPhotoPasted: (uri: string) => void onPhotoPasted: (uri: string) => void
onPressPublish: (richtext: RichText) => Promise<void> onPressPublish: (richtext: RichText) => void
onNewLink: (uri: string) => void onNewLink: (uri: string) => void
onError: (err: string) => void onError: (err: string) => void
} }