e40656128d
* 6861: Use available space to make focus on Composer TextInput easier * Undo web * bluesky-social#6861: Adjust focus area for native platforms taking Embeds into consideration * use atoms * remove empty div * remove debug atom, whoopsie --------- Co-authored-by: GrizliK1988 <dgrachikov@gmail.com> Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
1680 lines
46 KiB
TypeScript
1680 lines
46 KiB
TypeScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {
|
|
ActivityIndicator,
|
|
BackHandler,
|
|
Keyboard,
|
|
KeyboardAvoidingView,
|
|
LayoutChangeEvent,
|
|
ScrollView,
|
|
StyleProp,
|
|
StyleSheet,
|
|
View,
|
|
ViewStyle,
|
|
} from 'react-native'
|
|
// @ts-expect-error no type definition
|
|
import ProgressCircle from 'react-native-progress/Circle'
|
|
import Animated, {
|
|
AnimatedRef,
|
|
Easing,
|
|
FadeIn,
|
|
FadeOut,
|
|
interpolateColor,
|
|
LayoutAnimationConfig,
|
|
LinearTransition,
|
|
runOnUI,
|
|
scrollTo,
|
|
useAnimatedRef,
|
|
useAnimatedStyle,
|
|
useDerivedValue,
|
|
useSharedValue,
|
|
withRepeat,
|
|
withTiming,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
} from 'react-native-reanimated'
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
import {ImagePickerAsset} from 'expo-image-picker'
|
|
import {
|
|
AppBskyFeedDefs,
|
|
AppBskyFeedGetPostThread,
|
|
BskyAgent,
|
|
RichText,
|
|
} from '@atproto/api'
|
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
import {msg, Trans} from '@lingui/macro'
|
|
import {useLingui} from '@lingui/react'
|
|
import {useQueryClient} from '@tanstack/react-query'
|
|
|
|
import * as apilib from '#/lib/api/index'
|
|
import {EmbeddingDisabledError} from '#/lib/api/resolve'
|
|
import {until} from '#/lib/async/until'
|
|
import {
|
|
MAX_GRAPHEME_LENGTH,
|
|
SUPPORTED_MIME_TYPES,
|
|
SupportedMimeTypes,
|
|
} from '#/lib/constants'
|
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
|
import {useEmail} from '#/lib/hooks/useEmail'
|
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
import {usePalette} from '#/lib/hooks/usePalette'
|
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
import {mimeToExt} from '#/lib/media/video/util'
|
|
import {logEvent} from '#/lib/statsig/statsig'
|
|
import {cleanError} from '#/lib/strings/errors'
|
|
import {colors, s} from '#/lib/styles'
|
|
import {logger} from '#/logger'
|
|
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
|
import {emitPostCreated} from '#/state/events'
|
|
import {ComposerImage, pasteImage} from '#/state/gallery'
|
|
import {useModalControls} from '#/state/modals'
|
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
|
import {
|
|
toPostLanguages,
|
|
useLanguagePrefs,
|
|
useLanguagePrefsApi,
|
|
} from '#/state/preferences/languages'
|
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
|
import {useProfileQuery} from '#/state/queries/profile'
|
|
import {Gif} from '#/state/queries/tenor'
|
|
import {useAgent, useSession} from '#/state/session'
|
|
import {useComposerControls} from '#/state/shell/composer'
|
|
import {ComposerOpts} from '#/state/shell/composer'
|
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
|
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
|
import {
|
|
ExternalEmbedGif,
|
|
ExternalEmbedLink,
|
|
} from '#/view/com/composer/ExternalEmbed'
|
|
import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
|
|
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
|
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
|
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
|
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
|
import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
|
|
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
|
// TODO: Prevent naming components that coincide with RN primitives
|
|
// due to linting false positives
|
|
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
|
|
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
|
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
|
|
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
|
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
|
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
|
import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
|
|
import {Text} from '#/view/com/util/text/Text'
|
|
import * as Toast from '#/view/com/util/Toast'
|
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
import {atoms as a, native, useTheme} from '#/alf'
|
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
import {useDialogControl} from '#/components/Dialog'
|
|
import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
|
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
|
import * as Prompt from '#/components/Prompt'
|
|
import {Text as NewText} from '#/components/Typography'
|
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
|
import {
|
|
ComposerAction,
|
|
composerReducer,
|
|
createComposerState,
|
|
EmbedDraft,
|
|
MAX_IMAGES,
|
|
PostAction,
|
|
PostDraft,
|
|
ThreadDraft,
|
|
} from './state/composer'
|
|
import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
|
|
import {getVideoMetadata} from './videos/pickVideo'
|
|
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
|
|
|
type CancelRef = {
|
|
onPressCancel: () => void
|
|
}
|
|
|
|
type Props = ComposerOpts
|
|
export const ComposePost = ({
|
|
replyTo,
|
|
onPost,
|
|
quote: initQuote,
|
|
mention: initMention,
|
|
openEmojiPicker,
|
|
text: initText,
|
|
imageUris: initImageUris,
|
|
videoUri: initVideoUri,
|
|
cancelRef,
|
|
}: Props & {
|
|
cancelRef?: React.RefObject<CancelRef>
|
|
}) => {
|
|
const {currentAccount} = useSession()
|
|
const agent = useAgent()
|
|
const queryClient = useQueryClient()
|
|
const currentDid = currentAccount!.did
|
|
const {closeComposer} = useComposerControls()
|
|
const {_} = useLingui()
|
|
const requireAltTextEnabled = useRequireAltTextEnabled()
|
|
const langPrefs = useLanguagePrefs()
|
|
const setLangPrefs = useLanguagePrefsApi()
|
|
const textInput = useRef<TextInputRef>(null)
|
|
const discardPromptControl = Prompt.usePromptControl()
|
|
const {closeAllDialogs} = useDialogStateControlContext()
|
|
const {closeAllModals} = useModalControls()
|
|
const {data: preferences} = usePreferencesQuery()
|
|
|
|
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
|
|
const [isPublishing, setIsPublishing] = useState(false)
|
|
const [publishingStage, setPublishingStage] = useState('')
|
|
const [error, setError] = useState('')
|
|
|
|
const [composerState, composerDispatch] = useReducer(
|
|
composerReducer,
|
|
{
|
|
initImageUris,
|
|
initQuoteUri: initQuote?.uri,
|
|
initText,
|
|
initMention,
|
|
initInteractionSettings: preferences?.postInteractionSettings,
|
|
},
|
|
createComposerState,
|
|
)
|
|
|
|
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({
|
|
type: 'update_post',
|
|
postId: activePost.id,
|
|
postAction,
|
|
})
|
|
},
|
|
[activePost.id],
|
|
)
|
|
|
|
const selectVideo = React.useCallback(
|
|
(postId: string, asset: ImagePickerAsset) => {
|
|
const abortController = new AbortController()
|
|
composerDispatch({
|
|
type: 'update_post',
|
|
postId: postId,
|
|
postAction: {
|
|
type: 'embed_add_video',
|
|
asset,
|
|
abortController,
|
|
},
|
|
})
|
|
processVideo(
|
|
asset,
|
|
videoAction => {
|
|
composerDispatch({
|
|
type: 'update_post',
|
|
postId: postId,
|
|
postAction: {
|
|
type: 'embed_update_video',
|
|
videoAction,
|
|
},
|
|
})
|
|
},
|
|
agent,
|
|
currentDid,
|
|
abortController.signal,
|
|
_,
|
|
)
|
|
},
|
|
[_, agent, currentDid, composerDispatch],
|
|
)
|
|
|
|
const onInitVideo = useNonReactiveCallback(() => {
|
|
if (initVideoUri) {
|
|
selectVideo(activePost.id, initVideoUri)
|
|
}
|
|
})
|
|
|
|
useEffect(() => {
|
|
onInitVideo()
|
|
}, [onInitVideo])
|
|
|
|
const clearVideo = React.useCallback(
|
|
(postId: string) => {
|
|
composerDispatch({
|
|
type: 'update_post',
|
|
postId: postId,
|
|
postAction: {
|
|
type: 'embed_remove_video',
|
|
},
|
|
})
|
|
},
|
|
[composerDispatch],
|
|
)
|
|
|
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
|
|
|
const onClose = useCallback(() => {
|
|
closeComposer()
|
|
clearThumbnailCache(queryClient)
|
|
}, [closeComposer, queryClient])
|
|
|
|
const insets = useSafeAreaInsets()
|
|
const viewStyles = useMemo(
|
|
() => ({
|
|
paddingTop: isAndroid ? insets.top : 0,
|
|
paddingBottom:
|
|
// iOS - when keyboard is closed, keep the bottom bar in the safe area
|
|
(isIOS && !isKeyboardVisible) ||
|
|
// Android - Android >=35 KeyboardAvoidingView adds double padding when
|
|
// keyboard is closed, so we subtract that in the offset and add it back
|
|
// here when the keyboard is open
|
|
(isAndroid && isKeyboardVisible)
|
|
? insets.bottom
|
|
: 0,
|
|
}),
|
|
[insets, isKeyboardVisible],
|
|
)
|
|
|
|
const onPressCancel = useCallback(() => {
|
|
if (
|
|
thread.posts.some(
|
|
post =>
|
|
post.shortenedGraphemeLength > 0 ||
|
|
post.embed.media ||
|
|
post.embed.link,
|
|
)
|
|
) {
|
|
closeAllDialogs()
|
|
Keyboard.dismiss()
|
|
discardPromptControl.open()
|
|
} else {
|
|
onClose()
|
|
}
|
|
}, [thread, closeAllDialogs, discardPromptControl, onClose])
|
|
|
|
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
|
|
|
// On Android, pressing Back should ask confirmation.
|
|
useEffect(() => {
|
|
if (!isAndroid) {
|
|
return
|
|
}
|
|
const backHandler = BackHandler.addEventListener(
|
|
'hardwareBackPress',
|
|
() => {
|
|
if (closeAllDialogs() || closeAllModals()) {
|
|
return true
|
|
}
|
|
onPressCancel()
|
|
return true
|
|
},
|
|
)
|
|
return () => {
|
|
backHandler.remove()
|
|
}
|
|
}, [onPressCancel, closeAllDialogs, closeAllModals])
|
|
|
|
const {needsEmailVerification} = useEmail()
|
|
const emailVerificationControl = useDialogControl()
|
|
|
|
useEffect(() => {
|
|
if (needsEmailVerification) {
|
|
emailVerificationControl.open()
|
|
}
|
|
}, [needsEmailVerification, emailVerificationControl])
|
|
|
|
const missingAltError = useMemo(() => {
|
|
if (!requireAltTextEnabled) {
|
|
return
|
|
}
|
|
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 _(msg`One or more images is missing alt text.`)
|
|
}
|
|
if (media.type === 'gif' && !media.alt) {
|
|
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, _])
|
|
|
|
const canPost =
|
|
!missingAltError &&
|
|
thread.posts.every(
|
|
post =>
|
|
post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
|
|
!isEmptyPost(post) &&
|
|
!(
|
|
post.embed.media?.type === 'video' &&
|
|
post.embed.media.video.status === 'error'
|
|
),
|
|
)
|
|
|
|
const onPressPublish = React.useCallback(async () => {
|
|
if (isPublishing) {
|
|
return
|
|
}
|
|
|
|
if (!canPost) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
thread.posts.some(
|
|
post =>
|
|
post.embed.media?.type === 'video' &&
|
|
post.embed.media.video.asset &&
|
|
post.embed.media.video.status !== 'done',
|
|
)
|
|
) {
|
|
setPublishOnUpload(true)
|
|
return
|
|
}
|
|
|
|
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 {
|
|
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'),
|
|
})
|
|
|
|
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,
|
|
isPartOfThread: thread.posts.length > 1,
|
|
hasLink: !!post.embed.link,
|
|
hasQuote: !!post.embed.quote,
|
|
langs: langPrefs.postLanguage,
|
|
logContext: 'Composer',
|
|
})
|
|
index++
|
|
}
|
|
}
|
|
if (thread.posts.length > 1) {
|
|
logEvent('thread:create', {
|
|
postCount: thread.posts.length,
|
|
isReply: !!replyTo,
|
|
})
|
|
}
|
|
}
|
|
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(
|
|
thread.posts.length > 1
|
|
? _(msg`Your posts have been published`)
|
|
: 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.status === 'error') {
|
|
erroredVideos++
|
|
} else if (video.status !== 'done') {
|
|
uploadingVideos++
|
|
}
|
|
}
|
|
}
|
|
if (erroredVideos > 0) {
|
|
setPublishOnUpload(false)
|
|
} else if (uploadingVideos === 0) {
|
|
setPublishOnUpload(false)
|
|
onPressPublish()
|
|
}
|
|
}
|
|
}, [thread.posts, onPressPublish, publishOnUpload])
|
|
|
|
// TODO: It might make more sense to display this error per-post.
|
|
// Right now we're just displaying the first one.
|
|
let erroredVideoPostId: string | undefined
|
|
let erroredVideo: VideoState | NoVideoState = NO_VIDEO
|
|
for (let i = 0; i < thread.posts.length; i++) {
|
|
const post = thread.posts[i]
|
|
if (
|
|
post.embed.media?.type === 'video' &&
|
|
post.embed.media.video.status === 'error'
|
|
) {
|
|
erroredVideoPostId = post.id
|
|
erroredVideo = post.embed.media.video
|
|
break
|
|
}
|
|
}
|
|
|
|
const onEmojiButtonPress = useCallback(() => {
|
|
const rect = textInput.current?.getCursorPosition()
|
|
if (rect) {
|
|
openEmojiPicker?.({
|
|
...rect,
|
|
nextFocusRef:
|
|
textInput as unknown as React.MutableRefObject<HTMLElement>,
|
|
})
|
|
}
|
|
}, [openEmojiPicker])
|
|
|
|
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
|
useEffect(() => {
|
|
if (composerState.mutableNeedsFocusActive) {
|
|
composerState.mutableNeedsFocusActive = false
|
|
// On Android, this risks getting the cursor stuck behind the keyboard.
|
|
// Not worth it.
|
|
if (!isAndroid) {
|
|
textInput.current?.focus()
|
|
}
|
|
}
|
|
}, [composerState])
|
|
|
|
const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined
|
|
const {
|
|
scrollHandler,
|
|
onScrollViewContentSizeChange,
|
|
onScrollViewLayout,
|
|
topBarAnimatedStyle,
|
|
bottomBarAnimatedStyle,
|
|
} = useScrollTracker({
|
|
scrollViewRef,
|
|
stickyBottom: isLastThreadedPost,
|
|
})
|
|
|
|
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 isWebFooterSticky = !isNative && thread.posts.length > 1
|
|
return (
|
|
<BottomSheetPortalProvider>
|
|
<VerifyEmailDialog
|
|
control={emailVerificationControl}
|
|
onCloseWithoutVerifying={() => {
|
|
onClose()
|
|
}}
|
|
reasonText={_(
|
|
msg`Before creating a post, you must first verify your email.`,
|
|
)}
|
|
/>
|
|
<KeyboardAvoidingView
|
|
testID="composePostView"
|
|
behavior={isIOS ? 'padding' : 'height'}
|
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
|
style={a.flex_1}>
|
|
<View
|
|
style={[a.flex_1, viewStyles]}
|
|
aria-modal
|
|
accessibilityViewIsModal>
|
|
<ComposerTopBar
|
|
canPost={canPost}
|
|
isReply={!!replyTo}
|
|
isPublishQueued={publishOnUpload}
|
|
isPublishing={isPublishing}
|
|
isThread={thread.posts.length > 1}
|
|
publishingStage={publishingStage}
|
|
topBarAnimatedStyle={topBarAnimatedStyle}
|
|
onCancel={onPressCancel}
|
|
onPublish={onPressPublish}>
|
|
{missingAltError && <AltTextReminder error={missingAltError} />}
|
|
<ErrorBanner
|
|
error={error}
|
|
videoState={erroredVideo}
|
|
clearError={() => setError('')}
|
|
clearVideo={
|
|
erroredVideoPostId
|
|
? () => clearVideo(erroredVideoPostId)
|
|
: () => {}
|
|
}
|
|
/>
|
|
</ComposerTopBar>
|
|
|
|
<Animated.ScrollView
|
|
ref={scrollViewRef}
|
|
layout={native(LinearTransition)}
|
|
onScroll={scrollHandler}
|
|
contentContainerStyle={a.flex_grow}
|
|
style={a.flex_1}
|
|
keyboardShouldPersistTaps="always"
|
|
onContentSizeChange={onScrollViewContentSizeChange}
|
|
onLayout={onScrollViewLayout}>
|
|
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
|
{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}
|
|
isPartOfThread={thread.posts.length > 1}
|
|
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}
|
|
/>
|
|
{isWebFooterSticky && post.id === activePost.id && (
|
|
<View style={styles.stickyFooterWeb}>{footer}</View>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</Animated.ScrollView>
|
|
{!isWebFooterSticky && footer}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
let ComposerPost = React.memo(function ComposerPost({
|
|
post,
|
|
dispatch,
|
|
textInput,
|
|
isActive,
|
|
isReply,
|
|
isFirstPost,
|
|
isPartOfThread,
|
|
canRemovePost,
|
|
canRemoveQuote,
|
|
onClearVideo,
|
|
onSelectVideo,
|
|
onError,
|
|
onPublish,
|
|
}: {
|
|
post: PostDraft
|
|
dispatch: (action: ComposerAction) => void
|
|
textInput: React.Ref<TextInputRef>
|
|
isActive: boolean
|
|
isReply: boolean
|
|
isFirstPost: boolean
|
|
isPartOfThread: boolean
|
|
canRemovePost: boolean
|
|
canRemoveQuote: boolean
|
|
onClearVideo: (postId: string) => void
|
|
onSelectVideo: (postId: string, 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 = post.richtext
|
|
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
|
|
const forceMinHeight = isWeb && isTextOnly && isActive
|
|
const selectTextInputPlaceholder = isReply
|
|
? isFirstPost
|
|
? _(msg`Write your reply`)
|
|
: _(msg`Add another post`)
|
|
: _(msg`What's up?`)
|
|
const discardPromptControl = Prompt.usePromptControl()
|
|
|
|
const dispatchPost = useCallback(
|
|
(action: PostAction) => {
|
|
dispatch({
|
|
type: 'update_post',
|
|
postId: post.id,
|
|
postAction: action,
|
|
})
|
|
},
|
|
[dispatch, post.id],
|
|
)
|
|
|
|
const onImageAdd = useCallback(
|
|
(next: ComposerImage[]) => {
|
|
dispatchPost({
|
|
type: 'embed_add_images',
|
|
images: next,
|
|
})
|
|
},
|
|
[dispatchPost],
|
|
)
|
|
|
|
const onNewLink = useCallback(
|
|
(uri: string) => {
|
|
dispatchPost({type: 'embed_add_uri', uri})
|
|
},
|
|
[dispatchPost],
|
|
)
|
|
|
|
const onPhotoPasted = useCallback(
|
|
async (uri: string) => {
|
|
if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
|
|
if (isNative) return // web only
|
|
const [mimeType] = uri.slice('data:'.length).split(';')
|
|
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
|
|
Toast.show(_(msg`Unsupported video type`), 'xmark')
|
|
return
|
|
}
|
|
const name = `pasted.${mimeToExt(mimeType)}`
|
|
const file = await fetch(uri)
|
|
.then(res => res.blob())
|
|
.then(blob => new File([blob], name, {type: mimeType}))
|
|
onSelectVideo(post.id, await getVideoMetadata(file))
|
|
} else {
|
|
const res = await pasteImage(uri)
|
|
onImageAdd([res])
|
|
}
|
|
},
|
|
[post.id, onSelectVideo, onImageAdd, _],
|
|
)
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
a.mx_lg,
|
|
!isActive && styles.inactivePost,
|
|
isTextOnly && isNative && a.flex_grow,
|
|
]}>
|
|
<View style={[a.flex_row, isNative && a.flex_1]}>
|
|
<UserAvatar
|
|
avatar={currentProfile?.avatar}
|
|
size={50}
|
|
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
|
|
style={[a.mt_xs]}
|
|
/>
|
|
<TextInput
|
|
ref={textInput}
|
|
style={[a.pt_xs]}
|
|
richtext={richtext}
|
|
placeholder={selectTextInputPlaceholder}
|
|
autoFocus
|
|
webForceMinHeight={forceMinHeight}
|
|
// To avoid overlap with the close button:
|
|
hasRightPadding={isPartOfThread}
|
|
isActive={isActive}
|
|
setRichText={rt => {
|
|
dispatchPost({type: 'update_richtext', richtext: rt})
|
|
}}
|
|
onFocus={() => {
|
|
dispatch({
|
|
type: 'focus_post',
|
|
postId: post.id,
|
|
})
|
|
}}
|
|
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>
|
|
|
|
{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(post.id)}
|
|
isActivePost={isActive}
|
|
/>
|
|
</View>
|
|
)
|
|
})
|
|
|
|
function ComposerTopBar({
|
|
canPost,
|
|
isReply,
|
|
isPublishQueued,
|
|
isPublishing,
|
|
isThread,
|
|
publishingStage,
|
|
onCancel,
|
|
onPublish,
|
|
topBarAnimatedStyle,
|
|
children,
|
|
}: {
|
|
isPublishing: boolean
|
|
publishingStage: string
|
|
canPost: boolean
|
|
isReply: boolean
|
|
isPublishQueued: boolean
|
|
isThread: 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 style={pal.textLight}>{publishingStage}</Text>
|
|
<View style={styles.postBtn}>
|
|
<ActivityIndicator />
|
|
</View>
|
|
</>
|
|
) : (
|
|
<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={!canPost || isPublishQueued}>
|
|
<ButtonText style={[a.text_md]}>
|
|
{isReply ? (
|
|
<Trans context="action">Reply</Trans>
|
|
) : isThread ? (
|
|
<Trans context="action">Post All</Trans>
|
|
) : (
|
|
<Trans context="action">Post</Trans>
|
|
)}
|
|
</ButtonText>
|
|
</Button>
|
|
)}
|
|
</View>
|
|
{children}
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
function AltTextReminder({error}: {error: string}) {
|
|
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]}>{error}</Text>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function ComposerEmbeds({
|
|
embed,
|
|
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 (
|
|
<>
|
|
{embed.media?.type === 'images' && (
|
|
<Gallery images={embed.media.images} dispatch={dispatch} />
|
|
)}
|
|
|
|
{embed.media?.type === 'gif' && (
|
|
<View style={[a.relative, a.mt_lg]} 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, a.mt_lg]} 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}
|
|
isActivePost={isActivePost}
|
|
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>
|
|
{embed.quote?.uri ? (
|
|
<View style={!video ? [a.mt_md] : []}>
|
|
<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>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function ComposerPills({
|
|
isReply,
|
|
thread,
|
|
post,
|
|
dispatch,
|
|
bottomBarAnimatedStyle,
|
|
}: {
|
|
isReply: boolean
|
|
thread: ThreadDraft
|
|
post: PostDraft
|
|
dispatch: (action: ComposerAction) => void
|
|
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
|
}) {
|
|
const t = useTheme()
|
|
const media = post.embed.media
|
|
const hasMedia = media?.type === 'images' || media?.type === 'video'
|
|
const hasLink = !!post.embed.link
|
|
|
|
// Don't render anything if no pills are going to be displayed
|
|
if (isReply && !hasMedia && !hasLink) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
|
<ScrollView
|
|
contentContainerStyle={[a.gap_sm]}
|
|
horizontal={true}
|
|
bounces={false}
|
|
keyboardShouldPersistTaps="always"
|
|
showsHorizontalScrollIndicator={false}>
|
|
{isReply ? null : (
|
|
<ThreadgateBtn
|
|
postgate={thread.postgate}
|
|
onChangePostgate={nextPostgate => {
|
|
dispatch({type: 'update_postgate', postgate: nextPostgate})
|
|
}}
|
|
threadgateAllowUISettings={thread.threadgate}
|
|
onChangeThreadgateAllowUISettings={nextThreadgate => {
|
|
dispatch({
|
|
type: 'update_threadgate',
|
|
threadgate: nextThreadgate,
|
|
})
|
|
}}
|
|
style={bottomBarAnimatedStyle}
|
|
/>
|
|
)}
|
|
{hasMedia || hasLink ? (
|
|
<LabelsBtn
|
|
labels={post.labels}
|
|
onChange={nextLabels => {
|
|
dispatch({
|
|
type: 'update_post',
|
|
postId: post.id,
|
|
postAction: {
|
|
type: 'update_labels',
|
|
labels: nextLabels,
|
|
},
|
|
})
|
|
}}
|
|
/>
|
|
) : null}
|
|
</ScrollView>
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
function ComposerFooter({
|
|
post,
|
|
dispatch,
|
|
showAddButton,
|
|
onEmojiButtonPress,
|
|
onError,
|
|
onSelectVideo,
|
|
onAddPost,
|
|
}: {
|
|
post: PostDraft
|
|
dispatch: (action: PostAction) => void
|
|
showAddButton: boolean
|
|
onEmojiButtonPress: () => void
|
|
onError: (error: string) => void
|
|
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
|
|
onAddPost: () => void
|
|
}) {
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
const {isMobile} = useWebMediaQueries()
|
|
|
|
const media = post.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={asset => onSelectVideo(post.id, asset)}
|
|
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`Opens 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]}>
|
|
{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}
|
|
style={{width: 65}}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
export function useComposerCancelRef() {
|
|
return useRef<CancelRef>(null)
|
|
}
|
|
|
|
function useScrollTracker({
|
|
scrollViewRef,
|
|
stickyBottom,
|
|
}: {
|
|
scrollViewRef: AnimatedRef<Animated.ScrollView>
|
|
stickyBottom: boolean
|
|
}) {
|
|
const t = useTheme()
|
|
const contentOffset = useSharedValue(0)
|
|
const scrollViewHeight = useSharedValue(Infinity)
|
|
const contentHeight = useSharedValue(0)
|
|
|
|
const hasScrolledToTop = useDerivedValue(() =>
|
|
withTiming(contentOffset.get() === 0 ? 1 : 0),
|
|
)
|
|
|
|
const hasScrolledToBottom = useDerivedValue(() =>
|
|
withTiming(
|
|
contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get()
|
|
? 1
|
|
: 0,
|
|
),
|
|
)
|
|
|
|
const showHideBottomBorder = useCallback(
|
|
({
|
|
newContentHeight,
|
|
newContentOffset,
|
|
newScrollViewHeight,
|
|
}: {
|
|
newContentHeight?: number
|
|
newContentOffset?: number
|
|
newScrollViewHeight?: number
|
|
}) => {
|
|
'worklet'
|
|
if (typeof newContentHeight === 'number')
|
|
contentHeight.set(Math.floor(newContentHeight))
|
|
if (typeof newContentOffset === 'number')
|
|
contentOffset.set(Math.floor(newContentOffset))
|
|
if (typeof newScrollViewHeight === 'number')
|
|
scrollViewHeight.set(Math.floor(newScrollViewHeight))
|
|
},
|
|
[contentHeight, contentOffset, scrollViewHeight],
|
|
)
|
|
|
|
const scrollHandler = useAnimatedScrollHandler({
|
|
onScroll: event => {
|
|
'worklet'
|
|
showHideBottomBorder({
|
|
newContentOffset: event.contentOffset.y,
|
|
newContentHeight: event.contentSize.height,
|
|
newScrollViewHeight: event.layoutMeasurement.height,
|
|
})
|
|
},
|
|
})
|
|
|
|
const onScrollViewContentSizeChangeUIThread = useCallback(
|
|
(newContentHeight: number) => {
|
|
'worklet'
|
|
const oldContentHeight = contentHeight.get()
|
|
let shouldScrollToBottom = false
|
|
if (stickyBottom && newContentHeight > oldContentHeight) {
|
|
const isFairlyCloseToBottom =
|
|
oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get()
|
|
if (isFairlyCloseToBottom) {
|
|
shouldScrollToBottom = true
|
|
}
|
|
}
|
|
showHideBottomBorder({newContentHeight})
|
|
if (shouldScrollToBottom) {
|
|
scrollTo(scrollViewRef, 0, newContentHeight, true)
|
|
}
|
|
},
|
|
[
|
|
showHideBottomBorder,
|
|
scrollViewRef,
|
|
contentHeight,
|
|
stickyBottom,
|
|
contentOffset,
|
|
scrollViewHeight,
|
|
],
|
|
)
|
|
|
|
const onScrollViewContentSizeChange = useCallback(
|
|
(_width: number, height: number) => {
|
|
runOnUI(onScrollViewContentSizeChangeUIThread)(height)
|
|
},
|
|
[onScrollViewContentSizeChangeUIThread],
|
|
)
|
|
|
|
const onScrollViewLayout = useCallback(
|
|
(evt: LayoutChangeEvent) => {
|
|
showHideBottomBorder({
|
|
newScrollViewHeight: evt.nativeEvent.layout.height,
|
|
})
|
|
},
|
|
[showHideBottomBorder],
|
|
)
|
|
|
|
const topBarAnimatedStyle = useAnimatedStyle(() => {
|
|
return {
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderColor: interpolateColor(
|
|
hasScrolledToTop.get(),
|
|
[0, 1],
|
|
[t.atoms.border_contrast_medium.borderColor, 'transparent'],
|
|
),
|
|
}
|
|
})
|
|
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
|
|
return {
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
borderColor: interpolateColor(
|
|
hasScrolledToBottom.get(),
|
|
[0, 1],
|
|
[t.atoms.border_contrast_medium.borderColor, 'transparent'],
|
|
),
|
|
}
|
|
})
|
|
|
|
return {
|
|
scrollHandler,
|
|
onScrollViewContentSizeChange,
|
|
onScrollViewLayout,
|
|
topBarAnimatedStyle,
|
|
bottomBarAnimatedStyle,
|
|
}
|
|
}
|
|
|
|
function useKeyboardVerticalOffset() {
|
|
const {top, bottom} = useSafeAreaInsets()
|
|
|
|
// Android etc
|
|
if (!isIOS) {
|
|
// if Android <35 or web, bottom is 0 anyway. if >=35, this is needed to account
|
|
// for the edge-to-edge nav bar
|
|
return bottom * -1
|
|
}
|
|
|
|
// iPhone SE
|
|
if (top === 20) return 40
|
|
|
|
// all other iPhones
|
|
return top + 10
|
|
}
|
|
|
|
async function whenAppViewReady(
|
|
agent: BskyAgent,
|
|
uri: string,
|
|
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
|
|
) {
|
|
await until(
|
|
5, // 5 tries
|
|
1e3, // 1s delay between tries
|
|
fn,
|
|
() =>
|
|
agent.app.bsky.feed.getPostThread({
|
|
uri,
|
|
depth: 0,
|
|
}),
|
|
)
|
|
}
|
|
|
|
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',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 8,
|
|
height: 54,
|
|
gap: 4,
|
|
},
|
|
postBtn: {
|
|
borderRadius: 20,
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 6,
|
|
marginLeft: 12,
|
|
},
|
|
stickyFooterWeb: {
|
|
position: 'sticky',
|
|
bottom: 0,
|
|
},
|
|
errorLine: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.red1,
|
|
borderRadius: 6,
|
|
marginHorizontal: 16,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
marginBottom: 8,
|
|
},
|
|
reminderLine: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: 6,
|
|
marginHorizontal: 16,
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 6,
|
|
marginBottom: 8,
|
|
},
|
|
errorIcon: {
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: colors.red4,
|
|
color: colors.red4,
|
|
borderRadius: 30,
|
|
width: 16,
|
|
height: 16,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 5,
|
|
},
|
|
inactivePost: {
|
|
opacity: 0.5,
|
|
},
|
|
addExtLinkBtn: {
|
|
borderWidth: 1,
|
|
borderRadius: 24,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 12,
|
|
marginHorizontal: 10,
|
|
marginBottom: 4,
|
|
},
|
|
})
|
|
|
|
function ErrorBanner({
|
|
error: standardError,
|
|
videoState,
|
|
clearError,
|
|
clearVideo,
|
|
}: {
|
|
error: string
|
|
videoState: VideoState | NoVideoState
|
|
clearError: () => void
|
|
clearVideo: () => void
|
|
}) {
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
|
|
const videoError =
|
|
videoState.status === 'error' ? videoState.error : undefined
|
|
const error = standardError || videoError
|
|
|
|
const onClearError = () => {
|
|
if (standardError) {
|
|
clearError()
|
|
} else {
|
|
clearVideo()
|
|
}
|
|
}
|
|
|
|
if (!error) return null
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[a.px_lg, a.pb_sm]}
|
|
entering={FadeIn}
|
|
exiting={FadeOut}>
|
|
<View
|
|
style={[
|
|
a.px_md,
|
|
a.py_sm,
|
|
a.gap_xs,
|
|
a.rounded_sm,
|
|
t.atoms.bg_contrast_25,
|
|
]}>
|
|
<View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
|
|
<CircleInfo fill={t.palette.negative_400} />
|
|
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
|
{error}
|
|
</NewText>
|
|
<Button
|
|
label={_(msg`Dismiss error`)}
|
|
size="tiny"
|
|
color="secondary"
|
|
variant="ghost"
|
|
shape="round"
|
|
style={[a.absolute, {top: 0, right: 0}]}
|
|
onPress={onClearError}>
|
|
<ButtonIcon icon={X} />
|
|
</Button>
|
|
</View>
|
|
{videoError && videoState.jobId && (
|
|
<NewText
|
|
style={[
|
|
{paddingLeft: 28},
|
|
a.text_xs,
|
|
a.font_bold,
|
|
a.leading_snug,
|
|
t.atoms.text_contrast_low,
|
|
]}>
|
|
<Trans>Job ID: {videoState.jobId}</Trans>
|
|
</NewText>
|
|
)}
|
|
</View>
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
function ToolbarWrapper({
|
|
style,
|
|
children,
|
|
}: {
|
|
style: StyleProp<ViewStyle>
|
|
children: React.ReactNode
|
|
}) {
|
|
if (isWeb) return children
|
|
return (
|
|
<Animated.View
|
|
style={style}
|
|
entering={FadeIn.duration(400)}
|
|
exiting={FadeOut.duration(400)}>
|
|
{children}
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
function VideoUploadToolbar({state}: {state: VideoState}) {
|
|
const t = useTheme()
|
|
const {_} = useLingui()
|
|
const progress = state.progress
|
|
const shouldRotate =
|
|
state.status === 'processing' && (progress === 0 || progress === 1)
|
|
let wheelProgress = shouldRotate ? 0.33 : progress
|
|
|
|
const rotate = useDerivedValue(() => {
|
|
if (shouldRotate) {
|
|
return withRepeat(
|
|
withTiming(360, {
|
|
duration: 2500,
|
|
easing: Easing.out(Easing.cubic),
|
|
}),
|
|
-1,
|
|
)
|
|
}
|
|
return 0
|
|
})
|
|
|
|
const animatedStyle = useAnimatedStyle(() => {
|
|
return {
|
|
transform: [{rotateZ: `${rotate.get()}deg`}],
|
|
}
|
|
})
|
|
|
|
let text = ''
|
|
|
|
switch (state.status) {
|
|
case 'compressing':
|
|
text = _(msg`Compressing video...`)
|
|
break
|
|
case 'uploading':
|
|
text = _(msg`Uploading video...`)
|
|
break
|
|
case 'processing':
|
|
text = _(msg`Processing video...`)
|
|
break
|
|
case 'error':
|
|
text = _(msg`Error`)
|
|
wheelProgress = 100
|
|
break
|
|
case 'done':
|
|
text = _(msg`Video uploaded`)
|
|
break
|
|
}
|
|
|
|
return (
|
|
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
|
<Animated.View style={[animatedStyle]}>
|
|
<ProgressCircle
|
|
size={30}
|
|
borderWidth={1}
|
|
borderColor={t.atoms.border_contrast_low.borderColor}
|
|
color={
|
|
state.status === 'error'
|
|
? t.palette.negative_500
|
|
: t.palette.primary_500
|
|
}
|
|
progress={wheelProgress}
|
|
/>
|
|
</Animated.View>
|
|
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
|
</ToolbarWrapper>
|
|
)
|
|
}
|