forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {
2 useCallback,
3 useEffect,
4 useImperativeHandle,
5 useMemo,
6 useReducer,
7 useRef,
8 useState,
9} from 'react'
10import {
11 ActivityIndicator,
12 BackHandler,
13 Keyboard,
14 KeyboardAvoidingView,
15 type LayoutChangeEvent,
16 ScrollView,
17 type StyleProp,
18 StyleSheet,
19 View,
20 type ViewStyle,
21} from 'react-native'
22// @ts-expect-error no type definition
23import ProgressCircle from 'react-native-progress/Circle'
24import Animated, {
25 type AnimatedRef,
26 Easing,
27 FadeIn,
28 FadeOut,
29 interpolateColor,
30 LayoutAnimationConfig,
31 LinearTransition,
32 runOnUI,
33 scrollTo,
34 useAnimatedRef,
35 useAnimatedScrollHandler,
36 useAnimatedStyle,
37 useDerivedValue,
38 useSharedValue,
39 withRepeat,
40 withTiming,
41 ZoomIn,
42 ZoomOut,
43} from 'react-native-reanimated'
44import {useSafeAreaInsets} from 'react-native-safe-area-context'
45import {type ImagePickerAsset} from 'expo-image-picker'
46import {
47 AppBskyUnspeccedDefs,
48 type AppBskyUnspeccedGetPostThreadV2,
49 AtUri,
50 type BskyAgent,
51 type RichText,
52} from '@atproto/api'
53import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
54import {msg, plural, Trans} from '@lingui/macro'
55import {useLingui} from '@lingui/react'
56import {useNavigation} from '@react-navigation/native'
57import {useQueryClient} from '@tanstack/react-query'
58
59import * as apilib from '#/lib/api/index'
60import {EmbeddingDisabledError} from '#/lib/api/resolve'
61import {retry} from '#/lib/async/retry'
62import {until} from '#/lib/async/until'
63import {
64 MAX_GRAPHEME_LENGTH,
65 SUPPORTED_MIME_TYPES,
66 type SupportedMimeTypes,
67} from '#/lib/constants'
68import {useAppState} from '#/lib/hooks/useAppState'
69import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
70import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
71import {usePalette} from '#/lib/hooks/usePalette'
72import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
73import {mimeToExt} from '#/lib/media/video/util'
74import {type NavigationProp} from '#/lib/routes/types'
75import {logEvent} from '#/lib/statsig/statsig'
76import {cleanError} from '#/lib/strings/errors'
77import {getTerminology, TERMINOLOGY} from '#/lib/strings/terminology'
78import {colors} from '#/lib/styles'
79import {logger} from '#/logger'
80import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
81import {useDialogStateControlContext} from '#/state/dialogs'
82import {emitPostCreated} from '#/state/events'
83import {
84 type ComposerImage,
85 createComposerImage,
86 pasteImage,
87} from '#/state/gallery'
88import {useModalControls} from '#/state/modals'
89import {useRequireAltTextEnabled, useTerminologyPreference} from '#/state/preferences'
90import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
91import {
92 fromPostLanguages,
93 toPostLanguages,
94 useLanguagePrefs,
95 useLanguagePrefsApi,
96} from '#/state/preferences/languages'
97import {usePreferencesQuery} from '#/state/queries/preferences'
98import {useProfileQuery} from '#/state/queries/profile'
99import {type Gif} from '#/state/queries/tenor'
100import {useAgent, useSession} from '#/state/session'
101import {useComposerControls} from '#/state/shell/composer'
102import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
103import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
104import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
105import {
106 ExternalEmbedGif,
107 ExternalEmbedLink,
108} from '#/view/com/composer/ExternalEmbed'
109import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
110import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
111import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
112import {Gallery} from '#/view/com/composer/photos/Gallery'
113import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
114import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
115import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
116// TODO: Prevent naming components that coincide with RN primitives
117// due to linting false positives
118import {TextInput} from '#/view/com/composer/text-input/TextInput'
119import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
120import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
121import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft'
122import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
123import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
124import {Text} from '#/view/com/util/text/Text'
125import {UserAvatar} from '#/view/com/util/UserAvatar'
126import {atoms as a, native, useTheme, web} from '#/alf'
127import {Button, ButtonIcon, ButtonText} from '#/components/Button'
128import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
129import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
130import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
131import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
132import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
133import * as Prompt from '#/components/Prompt'
134import * as Toast from '#/components/Toast'
135import {Text as NewText} from '#/components/Typography'
136import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
137import {PostLanguageSelect} from './select-language/PostLanguageSelect'
138import {
139 type AssetType,
140 SelectMediaButton,
141 type SelectMediaButtonProps,
142} from './SelectMediaButton'
143import {
144 type ComposerAction,
145 composerReducer,
146 createComposerState,
147 type EmbedDraft,
148 MAX_IMAGES,
149 type PostAction,
150 type PostDraft,
151 type ThreadDraft,
152} from './state/composer'
153import {
154 NO_VIDEO,
155 type NoVideoState,
156 processVideo,
157 type VideoState,
158} from './state/video'
159import {type TextInputRef} from './text-input/TextInput.types'
160import {getVideoMetadata} from './videos/pickVideo'
161import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
162
163type CancelRef = {
164 onPressCancel: () => void
165}
166
167type Props = ComposerOpts
168export const ComposePost = ({
169 replyTo,
170 onPost,
171 onPostSuccess,
172 quote: initQuote,
173 mention: initMention,
174 openEmojiPicker,
175 text: initText,
176 imageUris: initImageUris,
177 videoUri: initVideoUri,
178 openGallery,
179 cancelRef,
180}: Props & {
181 cancelRef?: React.RefObject<CancelRef | null>
182}) => {
183 const {currentAccount} = useSession()
184 const agent = useAgent()
185 const queryClient = useQueryClient()
186 const currentDid = currentAccount!.did
187 const {closeComposer} = useComposerControls()
188 const {_} = useLingui()
189 const terminologyPreference = useTerminologyPreference()
190 const requireAltTextEnabled = useRequireAltTextEnabled()
191 const langPrefs = useLanguagePrefs()
192 const setLangPrefs = useLanguagePrefsApi()
193 const textInput = useRef<TextInputRef>(null)
194 const discardPromptControl = Prompt.usePromptControl()
195 const {closeAllDialogs} = useDialogStateControlContext()
196 const {closeAllModals} = useModalControls()
197 const {data: preferences} = usePreferencesQuery()
198 const navigation = useNavigation<NavigationProp>()
199
200 const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
201 const [isPublishing, setIsPublishing] = useState(false)
202 const [publishingStage, setPublishingStage] = useState('')
203 const [error, setError] = useState('')
204
205 /**
206 * A temporary local reference to a language suggestion that the user has
207 * accepted. This overrides the global post language preference, but is not
208 * stored permanently.
209 */
210 const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<
211 string | null
212 >(null)
213
214 /**
215 * The language(s) of the post being replied to.
216 */
217 const [replyToLanguages, setReplyToLanguages] = useState<string[]>(
218 replyTo?.langs || [],
219 )
220
221 /**
222 * The currently selected languages of the post. Prefer local temporary
223 * language suggestion over global lang prefs, if available.
224 */
225 const currentLanguages = useMemo(
226 () =>
227 acceptedLanguageSuggestion
228 ? [acceptedLanguageSuggestion]
229 : toPostLanguages(langPrefs.postLanguage),
230 [acceptedLanguageSuggestion, langPrefs.postLanguage],
231 )
232
233 /**
234 * When the user selects a language from the composer language selector,
235 * clear any temporary language suggestions they may have selected
236 * previously, and any we might try to suggest to them.
237 */
238 const onSelectLanguage = () => {
239 setAcceptedLanguageSuggestion(null)
240 setReplyToLanguages([])
241 }
242
243 const [composerState, composerDispatch] = useReducer(
244 composerReducer,
245 createComposerState({
246 initImageUris,
247 initQuoteUri: initQuote?.uri,
248 initText,
249 initMention,
250 initInteractionSettings: preferences?.postInteractionSettings,
251 initVideoUri,
252 }),
253 )
254
255 const thread = composerState.thread
256 const activePost = thread.posts[composerState.activePostIndex]
257 const nextPost: PostDraft | undefined =
258 thread.posts[composerState.activePostIndex + 1]
259 const dispatch = useCallback(
260 (postAction: PostAction) => {
261 composerDispatch({
262 type: 'update_post',
263 postId: activePost.id,
264 postAction,
265 })
266 },
267 [activePost.id],
268 )
269
270 const selectVideo = React.useCallback(
271 (postId: string, asset: ImagePickerAsset) => {
272 const abortController = new AbortController()
273 composerDispatch({
274 type: 'update_post',
275 postId: postId,
276 postAction: {
277 type: 'embed_add_video',
278 asset,
279 abortController,
280 },
281 })
282 processVideo(
283 asset,
284 videoAction => {
285 composerDispatch({
286 type: 'update_post',
287 postId: postId,
288 postAction: {
289 type: 'embed_update_video',
290 videoAction,
291 },
292 })
293 },
294 agent,
295 currentDid,
296 abortController.signal,
297 _,
298 )
299 },
300 [_, agent, currentDid, composerDispatch],
301 )
302
303 const onInitVideo = useNonReactiveCallback(() => {
304 if (initVideoUri && !initVideoUri.blobRef) {
305 selectVideo(activePost.id, initVideoUri)
306 }
307 })
308
309 useEffect(() => {
310 onInitVideo()
311 }, [onInitVideo])
312
313 const clearVideo = React.useCallback(
314 (postId: string) => {
315 composerDispatch({
316 type: 'update_post',
317 postId: postId,
318 postAction: {
319 type: 'embed_remove_video',
320 },
321 })
322 },
323 [composerDispatch],
324 )
325
326 const [publishOnUpload, setPublishOnUpload] = useState(false)
327
328 const onClose = useCallback(() => {
329 closeComposer()
330 clearThumbnailCache(queryClient)
331 }, [closeComposer, queryClient])
332
333 const insets = useSafeAreaInsets()
334 const viewStyles = useMemo(
335 () => ({
336 paddingTop: isAndroid ? insets.top : 0,
337 paddingBottom:
338 // iOS - when keyboard is closed, keep the bottom bar in the safe area
339 (isIOS && !isKeyboardVisible) ||
340 // Android - Android >=35 KeyboardAvoidingView adds double padding when
341 // keyboard is closed, so we subtract that in the offset and add it back
342 // here when the keyboard is open
343 (isAndroid && isKeyboardVisible)
344 ? insets.bottom
345 : 0,
346 }),
347 [insets, isKeyboardVisible],
348 )
349
350 const onPressCancel = useCallback(() => {
351 if (textInput.current?.maybeClosePopup()) {
352 return
353 } else if (
354 thread.posts.some(
355 post =>
356 post.shortenedGraphemeLength > 0 ||
357 post.embed.media ||
358 post.embed.link,
359 )
360 ) {
361 closeAllDialogs()
362 Keyboard.dismiss()
363 discardPromptControl.open()
364 } else {
365 onClose()
366 }
367 }, [thread, closeAllDialogs, discardPromptControl, onClose])
368
369 useImperativeHandle(cancelRef, () => ({onPressCancel}))
370
371 // On Android, pressing Back should ask confirmation.
372 useEffect(() => {
373 if (!isAndroid) {
374 return
375 }
376 const backHandler = BackHandler.addEventListener(
377 'hardwareBackPress',
378 () => {
379 if (closeAllDialogs() || closeAllModals()) {
380 return true
381 }
382 onPressCancel()
383 return true
384 },
385 )
386 return () => {
387 backHandler.remove()
388 }
389 }, [onPressCancel, closeAllDialogs, closeAllModals])
390
391 const missingAltError = useMemo(() => {
392 if (!requireAltTextEnabled) {
393 return
394 }
395 for (let i = 0; i < thread.posts.length; i++) {
396 const media = thread.posts[i].embed.media
397 if (media) {
398 if (media.type === 'images' && media.images.some(img => !img.alt)) {
399 return _(msg`One or more images is missing alt text.`)
400 }
401 if (media.type === 'gif' && !media.alt) {
402 return _(msg`One or more GIFs is missing alt text.`)
403 }
404 if (
405 media.type === 'video' &&
406 media.video.status !== 'error' &&
407 !media.video.altText
408 ) {
409 return _(msg`One or more videos is missing alt text.`)
410 }
411 }
412 }
413 }, [thread, requireAltTextEnabled, _])
414
415 const canPost =
416 !missingAltError &&
417 thread.posts.every(
418 post =>
419 post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
420 !isEmptyPost(post) &&
421 !(
422 post.embed.media?.type === 'video' &&
423 post.embed.media.video.status === 'error'
424 ),
425 )
426
427 const onPressPublish = React.useCallback(async () => {
428 if (isPublishing) {
429 return
430 }
431
432 if (!canPost) {
433 return
434 }
435
436 if (
437 thread.posts.some(
438 post =>
439 post.embed.media?.type === 'video' &&
440 post.embed.media.video.asset &&
441 post.embed.media.video.status !== 'done',
442 )
443 ) {
444 setPublishOnUpload(true)
445 return
446 }
447
448 setError('')
449 setIsPublishing(true)
450
451 let postUri: string | undefined
452 let postSuccessData: OnPostSuccessData
453 try {
454 logger.info(`composer: posting...`)
455 postUri = (
456 await apilib.post(agent, queryClient, {
457 thread,
458 replyTo: replyTo?.uri,
459 onStateChange: setPublishingStage,
460 langs: currentLanguages,
461 terminologyPreference,
462 })
463 ).uris[0]
464
465 /*
466 * Wait for app view to have received the post(s). If this fails, it's
467 * ok, because the post _was_ actually published above.
468 */
469 try {
470 if (postUri) {
471 logger.info(`composer: waiting for app view`)
472
473 const posts = await retry(
474 5,
475 _e => true,
476 async () => {
477 const res = await agent.app.bsky.unspecced.getPostThreadV2({
478 anchor: postUri!,
479 above: false,
480 below: thread.posts.length - 1,
481 branchingFactor: 1,
482 })
483 if (res.data.thread.length !== thread.posts.length) {
484 throw new Error(`composer: app view is not ready`)
485 }
486 if (
487 !res.data.thread.every(p =>
488 AppBskyUnspeccedDefs.isThreadItemPost(p.value),
489 )
490 ) {
491 throw new Error(`composer: app view returned non-post items`)
492 }
493 return res.data.thread
494 },
495 1e3,
496 )
497 postSuccessData = {
498 replyToUri: replyTo?.uri,
499 posts,
500 }
501 }
502 } catch (waitErr: any) {
503 logger.info(`composer: waiting for app view failed`, {
504 safeMessage: waitErr,
505 })
506 }
507 } catch (e: any) {
508 logger.error(e, {
509 message: `Composer: create post failed`,
510 hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
511 })
512
513 let err = cleanError(e.message)
514 if (err.includes('not locate record')) {
515 err = _(getTerminology(terminologyPreference, TERMINOLOGY.replyWasDeleted))
516 } else if (e instanceof EmbeddingDisabledError) {
517 err = _(getTerminology(terminologyPreference, TERMINOLOGY.quoteAuthorDisabled))
518 }
519 setError(err)
520 setIsPublishing(false)
521 return
522 } finally {
523 if (postUri) {
524 let index = 0
525 for (let post of thread.posts) {
526 logEvent('post:create', {
527 imageCount:
528 post.embed.media?.type === 'images'
529 ? post.embed.media.images.length
530 : 0,
531 isReply: index > 0 || !!replyTo,
532 isPartOfThread: thread.posts.length > 1,
533 hasLink: !!post.embed.link,
534 hasQuote: !!post.embed.quote,
535 langs: fromPostLanguages(currentLanguages),
536 logContext: 'Composer',
537 })
538 index++
539 }
540 }
541 if (thread.posts.length > 1) {
542 logEvent('thread:create', {
543 postCount: thread.posts.length,
544 isReply: !!replyTo,
545 })
546 }
547 }
548 if (postUri && !replyTo) {
549 emitPostCreated()
550 }
551 setLangPrefs.savePostLanguageToHistory()
552 if (initQuote) {
553 // We want to wait for the quote count to update before we call `onPost`, which will refetch data
554 whenAppViewReady(agent, initQuote.uri, res => {
555 const anchor = res.data.thread.at(0)
556 if (
557 AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
558 anchor.value.post.quoteCount !== initQuote.quoteCount
559 ) {
560 onPost?.(postUri)
561 onPostSuccess?.(postSuccessData)
562 return true
563 }
564 return false
565 })
566 } else {
567 onPost?.(postUri)
568 onPostSuccess?.(postSuccessData)
569 }
570 onClose()
571 setTimeout(() => {
572 Toast.show(
573 <Toast.Outer>
574 <Toast.Icon />
575 <Toast.Text>
576 {thread.posts.length > 1
577 ? _(getTerminology(terminologyPreference, TERMINOLOGY.sentPlural))
578 : replyTo
579 ? _(msg`Your reply was sent`)
580 : _(getTerminology(terminologyPreference, TERMINOLOGY.sent))}
581 </Toast.Text>
582 {postUri && (
583 <Toast.Action
584 label={_(getTerminology(terminologyPreference, TERMINOLOGY.view))}
585 onPress={() => {
586 const {host: name, rkey} = new AtUri(postUri)
587 navigation.navigate('PostThread', {name, rkey})
588 }}>
589 <Trans context="Action to view the skeet the user just created">
590 View
591 </Trans>
592 </Toast.Action>
593 )}
594 </Toast.Outer>,
595 {type: 'success'},
596 )
597 }, 500)
598 }, [
599 _,
600 agent,
601 thread,
602 canPost,
603 isPublishing,
604 currentLanguages,
605 onClose,
606 onPost,
607 onPostSuccess,
608 initQuote,
609 replyTo,
610 setLangPrefs,
611 queryClient,
612 navigation,
613 ])
614
615 // Preserves the referential identity passed to each post item.
616 // Avoids re-rendering all posts on each keystroke.
617 const onComposerPostPublish = useNonReactiveCallback(() => {
618 onPressPublish()
619 })
620
621 React.useEffect(() => {
622 if (publishOnUpload) {
623 let erroredVideos = 0
624 let uploadingVideos = 0
625 for (let post of thread.posts) {
626 if (post.embed.media?.type === 'video') {
627 const video = post.embed.media.video
628 if (video.status === 'error') {
629 erroredVideos++
630 } else if (video.status !== 'done') {
631 uploadingVideos++
632 }
633 }
634 }
635 if (erroredVideos > 0) {
636 setPublishOnUpload(false)
637 } else if (uploadingVideos === 0) {
638 setPublishOnUpload(false)
639 onPressPublish()
640 }
641 }
642 }, [thread.posts, onPressPublish, publishOnUpload])
643
644 // TODO: It might make more sense to display this error per-post.
645 // Right now we're just displaying the first one.
646 let erroredVideoPostId: string | undefined
647 let erroredVideo: VideoState | NoVideoState = NO_VIDEO
648 for (let i = 0; i < thread.posts.length; i++) {
649 const post = thread.posts[i]
650 if (
651 post.embed.media?.type === 'video' &&
652 post.embed.media.video.status === 'error'
653 ) {
654 erroredVideoPostId = post.id
655 erroredVideo = post.embed.media.video
656 break
657 }
658 }
659
660 const onEmojiButtonPress = useCallback(() => {
661 const rect = textInput.current?.getCursorPosition()
662 if (rect) {
663 openEmojiPicker?.({
664 ...rect,
665 nextFocusRef:
666 textInput as unknown as React.MutableRefObject<HTMLElement>,
667 })
668 }
669 }, [openEmojiPicker])
670
671 const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
672 useEffect(() => {
673 if (composerState.mutableNeedsFocusActive) {
674 composerState.mutableNeedsFocusActive = false
675 // On Android, this risks getting the cursor stuck behind the keyboard.
676 // Not worth it.
677 if (!isAndroid) {
678 textInput.current?.focus()
679 }
680 }
681 }, [composerState])
682
683 const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined
684 const {
685 scrollHandler,
686 onScrollViewContentSizeChange,
687 onScrollViewLayout,
688 topBarAnimatedStyle,
689 bottomBarAnimatedStyle,
690 } = useScrollTracker({
691 scrollViewRef,
692 stickyBottom: isLastThreadedPost,
693 })
694
695 const keyboardVerticalOffset = useKeyboardVerticalOffset()
696
697 const footer = (
698 <>
699 <SuggestedLanguage
700 text={activePost.richtext.text}
701 replyToLanguages={replyToLanguages}
702 currentLanguages={currentLanguages}
703 onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
704 />
705 <ComposerPills
706 isReply={!!replyTo}
707 post={activePost}
708 thread={composerState.thread}
709 dispatch={composerDispatch}
710 bottomBarAnimatedStyle={bottomBarAnimatedStyle}
711 />
712 <ComposerFooter
713 post={activePost}
714 dispatch={dispatch}
715 showAddButton={
716 !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
717 }
718 onError={setError}
719 onEmojiButtonPress={onEmojiButtonPress}
720 onSelectVideo={selectVideo}
721 onAddPost={() => {
722 composerDispatch({
723 type: 'add_post',
724 })
725 }}
726 currentLanguages={currentLanguages}
727 onSelectLanguage={onSelectLanguage}
728 openGallery={openGallery}
729 />
730 </>
731 )
732
733 const isWebFooterSticky = !isNative && thread.posts.length > 1
734 return (
735 <BottomSheetPortalProvider>
736 <KeyboardAvoidingView
737 testID="composePostView"
738 behavior={isIOS ? 'padding' : 'height'}
739 keyboardVerticalOffset={keyboardVerticalOffset}
740 style={a.flex_1}>
741 <View
742 style={[a.flex_1, viewStyles]}
743 aria-modal
744 accessibilityViewIsModal>
745 <ComposerTopBar
746 canPost={canPost}
747 isReply={!!replyTo}
748 isPublishQueued={publishOnUpload}
749 isPublishing={isPublishing}
750 isThread={thread.posts.length > 1}
751 publishingStage={publishingStage}
752 topBarAnimatedStyle={topBarAnimatedStyle}
753 onCancel={onPressCancel}
754 onPublish={onPressPublish}>
755 {missingAltError && <AltTextReminder error={missingAltError} />}
756 <ErrorBanner
757 error={error}
758 videoState={erroredVideo}
759 clearError={() => setError('')}
760 clearVideo={
761 erroredVideoPostId
762 ? () => clearVideo(erroredVideoPostId)
763 : () => {}
764 }
765 />
766 </ComposerTopBar>
767
768 <Animated.ScrollView
769 ref={scrollViewRef}
770 layout={native(LinearTransition)}
771 onScroll={scrollHandler}
772 contentContainerStyle={a.flex_grow}
773 style={a.flex_1}
774 keyboardShouldPersistTaps="always"
775 onContentSizeChange={onScrollViewContentSizeChange}
776 onLayout={onScrollViewLayout}>
777 {replyTo && replyTo.text && replyTo.author ? (
778 <ComposerReplyTo replyTo={replyTo} />
779 ) : undefined}
780 {thread.posts.map((post, index) => (
781 <React.Fragment key={post.id}>
782 <ComposerPost
783 post={post}
784 dispatch={composerDispatch}
785 textInput={post.id === activePost.id ? textInput : null}
786 isFirstPost={index === 0}
787 isLastPost={index === thread.posts.length - 1}
788 isPartOfThread={thread.posts.length > 1}
789 isReply={index > 0 || !!replyTo}
790 isActive={post.id === activePost.id}
791 canRemovePost={thread.posts.length > 1}
792 canRemoveQuote={index > 0 || !initQuote}
793 onSelectVideo={selectVideo}
794 onClearVideo={clearVideo}
795 onPublish={onComposerPostPublish}
796 onError={setError}
797 />
798 {isWebFooterSticky && post.id === activePost.id && (
799 <View style={styles.stickyFooterWeb}>{footer}</View>
800 )}
801 </React.Fragment>
802 ))}
803 </Animated.ScrollView>
804 {!isWebFooterSticky && footer}
805 </View>
806
807 <Prompt.Basic
808 control={discardPromptControl}
809 title={_(msg`Discard draft?`)}
810 description={_(msg`Are you sure you'd like to discard this draft?`)}
811 onConfirm={onClose}
812 confirmButtonCta={_(msg`Discard`)}
813 confirmButtonColor="negative"
814 />
815 </KeyboardAvoidingView>
816 </BottomSheetPortalProvider>
817 )
818}
819
820let ComposerPost = React.memo(function ComposerPost({
821 post,
822 dispatch,
823 textInput,
824 isActive,
825 isReply,
826 isFirstPost,
827 isLastPost,
828 isPartOfThread,
829 canRemovePost,
830 canRemoveQuote,
831 onClearVideo,
832 onSelectVideo,
833 onError,
834 onPublish,
835}: {
836 post: PostDraft
837 dispatch: (action: ComposerAction) => void
838 textInput: React.Ref<TextInputRef>
839 isActive: boolean
840 isReply: boolean
841 isFirstPost: boolean
842 isLastPost: boolean
843 isPartOfThread: boolean
844 canRemovePost: boolean
845 canRemoveQuote: boolean
846 onClearVideo: (postId: string) => void
847 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
848 onError: (error: string) => void
849 onPublish: (richtext: RichText) => void
850}) {
851 const {currentAccount} = useSession()
852 const currentDid = currentAccount!.did
853 const {_} = useLingui()
854 const {data: currentProfile} = useProfileQuery({did: currentDid})
855 const richtext = post.richtext
856 const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
857 const forceMinHeight = isWeb && isTextOnly && isActive
858 const selectTextInputPlaceholder = isReply
859 ? isFirstPost
860 ? _(msg`Write your reply`)
861 : _(getTerminology(terminologyPreference, TERMINOLOGY.addAnother))
862 : _(getTerminology(terminologyPreference, TERMINOLOGY.anythingBut))
863 const discardPromptControl = Prompt.usePromptControl()
864
865 const enableSquareButtons = useEnableSquareButtons()
866
867 const dispatchPost = useCallback(
868 (action: PostAction) => {
869 dispatch({
870 type: 'update_post',
871 postId: post.id,
872 postAction: action,
873 })
874 },
875 [dispatch, post.id],
876 )
877
878 const onImageAdd = useCallback(
879 (next: ComposerImage[]) => {
880 dispatchPost({
881 type: 'embed_add_images',
882 images: next,
883 })
884 },
885 [dispatchPost],
886 )
887
888 const onNewLink = useCallback(
889 (uri: string) => {
890 dispatchPost({type: 'embed_add_uri', uri})
891 },
892 [dispatchPost],
893 )
894
895 const onPhotoPasted = useCallback(
896 async (uri: string) => {
897 if (
898 uri.startsWith('data:video/') ||
899 (isWeb && uri.startsWith('data:image/gif'))
900 ) {
901 if (isNative) return // web only
902 const [mimeType] = uri.slice('data:'.length).split(';')
903 if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
904 Toast.show(_(msg`Unsupported video type: ${mimeType}`), {
905 type: 'error',
906 })
907 return
908 }
909 const name = `pasted.${mimeToExt(mimeType)}`
910 const file = await fetch(uri)
911 .then(res => res.blob())
912 .then(blob => new File([blob], name, {type: mimeType}))
913 onSelectVideo(post.id, await getVideoMetadata(file))
914 } else {
915 const res = await pasteImage(uri)
916 onImageAdd([res])
917 }
918 },
919 [post.id, onSelectVideo, onImageAdd, _],
920 )
921
922 useHideKeyboardOnBackground()
923
924 return (
925 <View
926 style={[
927 a.mx_lg,
928 a.mb_sm,
929 !isActive && isLastPost && a.mb_lg,
930 !isActive && styles.inactivePost,
931 isTextOnly && isNative && a.flex_grow,
932 ]}>
933 <View style={[a.flex_row, isNative && a.flex_1]}>
934 <UserAvatar
935 avatar={currentProfile?.avatar}
936 size={42}
937 type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
938 style={[a.mt_xs]}
939 />
940 <TextInput
941 ref={textInput}
942 style={[a.pt_xs]}
943 richtext={richtext}
944 placeholder={selectTextInputPlaceholder}
945 autoFocus
946 webForceMinHeight={forceMinHeight}
947 // To avoid overlap with the close button:
948 hasRightPadding={isPartOfThread}
949 isActive={isActive}
950 setRichText={rt => {
951 dispatchPost({type: 'update_richtext', richtext: rt})
952 }}
953 onFocus={() => {
954 dispatch({
955 type: 'focus_post',
956 postId: post.id,
957 })
958 }}
959 onPhotoPasted={onPhotoPasted}
960 onNewLink={onNewLink}
961 onError={onError}
962 onPressPublish={onPublish}
963 accessible={true}
964 accessibilityLabel={_(msg`Write post`)}
965 accessibilityHint={_(
966 msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, {
967 other: '# characters',
968 })} in length`,
969 )}
970 />
971 </View>
972
973 {canRemovePost && isActive && (
974 <>
975 <Button
976 label={_(getTerminology(terminologyPreference, TERMINOLOGY.delete))}
977 size="small"
978 color="secondary"
979 variant="ghost"
980 shape={enableSquareButtons ? 'square' : 'round'}
981 style={[a.absolute, {top: 0, right: 0}]}
982 onPress={() => {
983 if (
984 post.shortenedGraphemeLength > 0 ||
985 post.embed.media ||
986 post.embed.link ||
987 post.embed.quote
988 ) {
989 discardPromptControl.open()
990 } else {
991 dispatch({
992 type: 'remove_post',
993 postId: post.id,
994 })
995 }
996 }}>
997 <ButtonIcon icon={XIcon} />
998 </Button>
999 <Prompt.Basic
1000 control={discardPromptControl}
1001 title={_(getTerminology(terminologyPreference, TERMINOLOGY.discard))}
1002 description={_(getTerminology(terminologyPreference, TERMINOLOGY.discardConfirm))}
1003 onConfirm={() => {
1004 dispatch({
1005 type: 'remove_post',
1006 postId: post.id,
1007 })
1008 }}
1009 confirmButtonCta={_(msg`Discard`)}
1010 confirmButtonColor="negative"
1011 />
1012 </>
1013 )}
1014
1015 <ComposerEmbeds
1016 canRemoveQuote={canRemoveQuote}
1017 embed={post.embed}
1018 dispatch={dispatchPost}
1019 clearVideo={() => onClearVideo(post.id)}
1020 isActivePost={isActive}
1021 />
1022 </View>
1023 )
1024})
1025
1026function ComposerTopBar({
1027 canPost,
1028 isReply,
1029 isPublishQueued,
1030 isPublishing,
1031 isThread,
1032 publishingStage,
1033 onCancel,
1034 onPublish,
1035 topBarAnimatedStyle,
1036 children,
1037}: {
1038 isPublishing: boolean
1039 publishingStage: string
1040 canPost: boolean
1041 isReply: boolean
1042 isPublishQueued: boolean
1043 isThread: boolean
1044 onCancel: () => void
1045 onPublish: () => void
1046 topBarAnimatedStyle: StyleProp<ViewStyle>
1047 children?: React.ReactNode
1048}) {
1049 const t = useTheme()
1050 const pal = usePalette('default')
1051 const {_} = useLingui()
1052
1053 const enableSquareButtons = useEnableSquareButtons()
1054
1055 return (
1056 <Animated.View
1057 style={topBarAnimatedStyle}
1058 layout={native(LinearTransition)}>
1059 <View style={styles.topbarInner}>
1060 <Button
1061 label={_(msg`Cancel`)}
1062 variant="ghost"
1063 color="primary"
1064 shape="default"
1065 size="small"
1066 style={[
1067 enableSquareButtons ? a.rounded_sm : a.rounded_full,
1068 a.py_sm,
1069 {paddingLeft: 7, paddingRight: 7},
1070 ]}
1071 onPress={onCancel}
1072 accessibilityHint={_(
1073 msg`Closes post composer and discards post draft`,
1074 )}>
1075 <ButtonText style={[a.text_md]}>
1076 <Trans>Cancel</Trans>
1077 </ButtonText>
1078 </Button>
1079 <View style={a.flex_1} />
1080 {isPublishing ? (
1081 <>
1082 <Text style={pal.textLight}>{publishingStage}</Text>
1083 <View style={styles.postBtn}>
1084 <ActivityIndicator color={t.palette.primary_500} />
1085 </View>
1086 </>
1087 ) : (
1088 <Button
1089 testID="composerPublishBtn"
1090 label={
1091 isReply
1092 ? isThread
1093 ? _(
1094 msg({
1095 message: 'Publish replies',
1096 comment:
1097 'Accessibility label for button to publish multiple replies in a thread',
1098 }),
1099 )
1100 : _(
1101 msg({
1102 message: 'Publish reply',
1103 comment:
1104 'Accessibility label for button to publish a single reply',
1105 }),
1106 )
1107 : isThread
1108 ? _(
1109 msg({
1110 message: 'Publish posts',
1111 comment:
1112 'Accessibility label for button to publish multiple posts in a thread',
1113 }),
1114 )
1115 : _(
1116 msg({
1117 message: 'Publish post',
1118 comment:
1119 'Accessibility label for button to publish a single post',
1120 }),
1121 )
1122 }
1123 variant="solid"
1124 color="primary"
1125 shape="default"
1126 size="small"
1127 style={[
1128 enableSquareButtons ? a.rounded_sm : a.rounded_full,
1129 a.py_sm,
1130 ]}
1131 onPress={onPublish}
1132 disabled={!canPost || isPublishQueued}>
1133 <ButtonText style={[a.text_md]}>
1134 {isReply ? (
1135 <Trans context="action">Reply</Trans>
1136 ) : isThread ? (
1137 <Trans context="action">{_(getTerminology(terminologyPreference, TERMINOLOGY.postAll))}</Trans>
1138 ) : (
1139 <Trans context="action">{_(getTerminology(terminologyPreference, TERMINOLOGY.postSingle))}</Trans>
1140 )}
1141 </ButtonText>
1142 </Button>
1143 )}
1144 </View>
1145 {children}
1146 </Animated.View>
1147 )
1148}
1149
1150function AltTextReminder({error}: {error: string}) {
1151 const pal = usePalette('default')
1152 return (
1153 <View style={[styles.reminderLine, pal.viewLight]}>
1154 <View style={styles.errorIcon}>
1155 <FontAwesomeIcon
1156 icon="exclamation"
1157 style={{color: colors.red4}}
1158 size={10}
1159 />
1160 </View>
1161 <Text style={[pal.text, a.flex_1]}>{error}</Text>
1162 </View>
1163 )
1164}
1165
1166function ComposerEmbeds({
1167 embed,
1168 dispatch,
1169 clearVideo,
1170 canRemoveQuote,
1171 isActivePost,
1172}: {
1173 embed: EmbedDraft
1174 dispatch: (action: PostAction) => void
1175 clearVideo: () => void
1176 canRemoveQuote: boolean
1177 isActivePost: boolean
1178}) {
1179 const video = embed.media?.type === 'video' ? embed.media.video : null
1180 return (
1181 <>
1182 {embed.media?.type === 'images' && (
1183 <Gallery images={embed.media.images} dispatch={dispatch} />
1184 )}
1185
1186 {embed.media?.type === 'gif' && (
1187 <View style={[a.relative, a.mt_lg]} key={embed.media.gif.url}>
1188 <ExternalEmbedGif
1189 gif={embed.media.gif}
1190 onRemove={() => dispatch({type: 'embed_remove_gif'})}
1191 />
1192 <GifAltTextDialog
1193 gif={embed.media.gif}
1194 altText={embed.media.alt ?? ''}
1195 onSubmit={(altText: string) => {
1196 dispatch({type: 'embed_update_gif', alt: altText})
1197 }}
1198 />
1199 </View>
1200 )}
1201
1202 {!embed.media && embed.link && (
1203 <View style={[a.relative, a.mt_lg]} key={embed.link.uri}>
1204 <ExternalEmbedLink
1205 uri={embed.link.uri}
1206 hasQuote={!!embed.quote}
1207 onRemove={() => dispatch({type: 'embed_remove_link'})}
1208 />
1209 </View>
1210 )}
1211
1212 <LayoutAnimationConfig skipExiting>
1213 {video && (
1214 <Animated.View
1215 style={[a.w_full, a.mt_lg]}
1216 entering={native(ZoomIn)}
1217 exiting={native(ZoomOut)}>
1218 {video.asset &&
1219 (video.status === 'compressing' ? (
1220 <VideoTranscodeProgress
1221 asset={video.asset}
1222 progress={video.progress}
1223 clear={clearVideo}
1224 />
1225 ) : video.video ? (
1226 <VideoPreview
1227 asset={video.asset}
1228 video={video.video}
1229 isActivePost={isActivePost}
1230 clear={clearVideo}
1231 />
1232 ) : null)}
1233 {!video.asset &&
1234 video.status === 'done' &&
1235 'playlistUri' in video && (
1236 <View style={[a.relative, a.mt_lg]}>
1237 <VideoEmbedRedraft
1238 blobRef={video.pendingPublish?.blobRef!}
1239 playlistUri={video.playlistUri}
1240 aspectRatio={video.redraftDimensions}
1241 onRemove={clearVideo}
1242 />
1243 </View>
1244 )}
1245 <SubtitleDialogBtn
1246 defaultAltText={video.altText}
1247 saveAltText={altText =>
1248 dispatch({
1249 type: 'embed_update_video',
1250 videoAction: {
1251 type: 'update_alt_text',
1252 altText,
1253 signal: video.abortController.signal,
1254 },
1255 })
1256 }
1257 captions={video.captions}
1258 setCaptions={(updater: (captions: any[]) => any[]) => {
1259 dispatch({
1260 type: 'embed_update_video',
1261 videoAction: {
1262 type: 'update_captions',
1263 updater,
1264 signal: video.abortController.signal,
1265 },
1266 })
1267 }}
1268 />
1269 </Animated.View>
1270 )}
1271 </LayoutAnimationConfig>
1272 {embed.quote?.uri ? (
1273 <View
1274 style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], isWeb && [a.pb_md]]}>
1275 <View style={[a.relative]}>
1276 <View style={{pointerEvents: 'none'}}>
1277 <LazyQuoteEmbed uri={embed.quote.uri} />
1278 </View>
1279 {canRemoveQuote && (
1280 <ExternalEmbedRemoveBtn
1281 onRemove={() => dispatch({type: 'embed_remove_quote'})}
1282 style={{top: 16}}
1283 />
1284 )}
1285 </View>
1286 </View>
1287 ) : null}
1288 </>
1289 )
1290}
1291
1292function ComposerPills({
1293 isReply,
1294 thread,
1295 post,
1296 dispatch,
1297 bottomBarAnimatedStyle,
1298}: {
1299 isReply: boolean
1300 thread: ThreadDraft
1301 post: PostDraft
1302 dispatch: (action: ComposerAction) => void
1303 bottomBarAnimatedStyle: StyleProp<ViewStyle>
1304}) {
1305 const t = useTheme()
1306 const media = post.embed.media
1307 const hasMedia = media?.type === 'images' || media?.type === 'video'
1308 const hasLink = !!post.embed.link
1309
1310 // Don't render anything if no pills are going to be displayed
1311 if (isReply && !hasMedia && !hasLink) {
1312 return null
1313 }
1314
1315 return (
1316 <Animated.View
1317 style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
1318 <ScrollView
1319 contentContainerStyle={[a.gap_sm]}
1320 horizontal={true}
1321 bounces={false}
1322 keyboardShouldPersistTaps="always"
1323 showsHorizontalScrollIndicator={false}>
1324 {isReply ? null : (
1325 <ThreadgateBtn
1326 postgate={thread.postgate}
1327 onChangePostgate={nextPostgate => {
1328 dispatch({type: 'update_postgate', postgate: nextPostgate})
1329 }}
1330 threadgateAllowUISettings={thread.threadgate}
1331 onChangeThreadgateAllowUISettings={nextThreadgate => {
1332 dispatch({
1333 type: 'update_threadgate',
1334 threadgate: nextThreadgate,
1335 })
1336 }}
1337 style={bottomBarAnimatedStyle}
1338 />
1339 )}
1340 {hasMedia || hasLink ? (
1341 <LabelsBtn
1342 labels={post.labels}
1343 onChange={nextLabels => {
1344 dispatch({
1345 type: 'update_post',
1346 postId: post.id,
1347 postAction: {
1348 type: 'update_labels',
1349 labels: nextLabels,
1350 },
1351 })
1352 }}
1353 />
1354 ) : null}
1355 </ScrollView>
1356 </Animated.View>
1357 )
1358}
1359
1360function ComposerFooter({
1361 post,
1362 dispatch,
1363 showAddButton,
1364 onEmojiButtonPress,
1365 onSelectVideo,
1366 onAddPost,
1367 currentLanguages,
1368 onSelectLanguage,
1369 openGallery,
1370}: {
1371 post: PostDraft
1372 dispatch: (action: PostAction) => void
1373 showAddButton: boolean
1374 onEmojiButtonPress: () => void
1375 onError: (error: string) => void
1376 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
1377 onAddPost: () => void
1378 currentLanguages: string[]
1379 onSelectLanguage?: (language: string) => void
1380 openGallery?: boolean
1381}) {
1382 const t = useTheme()
1383 const {_} = useLingui()
1384 const {isMobile} = useWebMediaQueries()
1385 /*
1386 * Once we've allowed a certain type of asset to be selected, we don't allow
1387 * other types of media to be selected.
1388 */
1389 const [selectedAssetsType, setSelectedAssetsType] = useState<
1390 AssetType | undefined
1391 >(undefined)
1392
1393 const media = post.embed.media
1394 const images = media?.type === 'images' ? media.images : []
1395 const video = media?.type === 'video' ? media.video : null
1396 const isMaxImages = images.length >= MAX_IMAGES
1397 const isMaxVideos = !!video
1398
1399 let selectedAssetsCount = 0
1400 let isMediaSelectionDisabled = false
1401
1402 const enableSquareButtons = useEnableSquareButtons()
1403
1404 if (media?.type === 'images') {
1405 isMediaSelectionDisabled = isMaxImages
1406 selectedAssetsCount = images.length
1407 } else if (media?.type === 'video') {
1408 isMediaSelectionDisabled = isMaxVideos
1409 selectedAssetsCount = 1
1410 } else {
1411 isMediaSelectionDisabled = !!media
1412 }
1413
1414 const onImageAdd = useCallback(
1415 (next: ComposerImage[]) => {
1416 dispatch({
1417 type: 'embed_add_images',
1418 images: next,
1419 })
1420 },
1421 [dispatch],
1422 )
1423
1424 const onSelectGif = useCallback(
1425 (gif: Gif) => {
1426 dispatch({type: 'embed_add_gif', gif})
1427 },
1428 [dispatch],
1429 )
1430
1431 /*
1432 * Reset if the user clears any selected media
1433 */
1434 if (selectedAssetsType !== undefined && !media) {
1435 setSelectedAssetsType(undefined)
1436 }
1437
1438 const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>(
1439 async ({type, assets, errors}) => {
1440 setSelectedAssetsType(type)
1441
1442 if (assets.length) {
1443 if (type === 'image') {
1444 const composerImages: ComposerImage[] = []
1445
1446 await Promise.all(
1447 assets.map(async image => {
1448 const composerImage = await createComposerImage({
1449 path: image.uri,
1450 width: image.width,
1451 height: image.height,
1452 mime: image.mimeType!,
1453 })
1454 composerImages.push(composerImage)
1455 }),
1456 ).catch(e => {
1457 logger.error(`createComposerImage failed`, {
1458 safeMessage: e.message,
1459 })
1460 })
1461
1462 onImageAdd(composerImages)
1463 } else if (type === 'video') {
1464 onSelectVideo(post.id, assets[0])
1465 } else if (type === 'gif') {
1466 onSelectVideo(post.id, assets[0])
1467 }
1468 }
1469
1470 errors.map(error => {
1471 Toast.show(error, {
1472 type: 'warning',
1473 })
1474 })
1475 },
1476 [post.id, onSelectVideo, onImageAdd],
1477 )
1478
1479 return (
1480 <View
1481 style={[
1482 a.flex_row,
1483 a.py_xs,
1484 {paddingLeft: 7, paddingRight: 16},
1485 a.align_center,
1486 a.border_t,
1487 t.atoms.bg,
1488 t.atoms.border_contrast_medium,
1489 a.justify_between,
1490 ]}>
1491 <View style={[a.flex_row, a.align_center]}>
1492 <LayoutAnimationConfig skipEntering skipExiting>
1493 {video && video.status !== 'done' ? (
1494 <VideoUploadToolbar state={video} />
1495 ) : (
1496 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
1497 <SelectMediaButton
1498 disabled={isMediaSelectionDisabled}
1499 allowedAssetTypes={selectedAssetsType}
1500 selectedAssetsCount={selectedAssetsCount}
1501 onSelectAssets={onSelectAssets}
1502 autoOpen={openGallery}
1503 />
1504 <OpenCameraBtn
1505 disabled={media?.type === 'images' ? isMaxImages : !!media}
1506 onAdd={onImageAdd}
1507 />
1508 <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
1509 {!isMobile ? (
1510 <Button
1511 onPress={onEmojiButtonPress}
1512 style={a.p_sm}
1513 label={_(msg`Open emoji picker`)}
1514 accessibilityHint={_(msg`Opens emoji picker`)}
1515 variant="ghost"
1516 shape={enableSquareButtons ? 'square' : 'round'}
1517 color="primary">
1518 <EmojiSmileIcon size="lg" />
1519 </Button>
1520 ) : null}
1521 </ToolbarWrapper>
1522 )}
1523 </LayoutAnimationConfig>
1524 </View>
1525 <View style={[a.flex_row, a.align_center, a.justify_between]}>
1526 {showAddButton && (
1527 <Button
1528 label={_(msg`Add another post to thread`)}
1529 onPress={onAddPost}
1530 style={[a.p_sm]}
1531 variant="ghost"
1532 shape={enableSquareButtons ? 'square' : 'round'}
1533 color="primary">
1534 <PlusIcon size="lg" />
1535 </Button>
1536 )}
1537 <PostLanguageSelect
1538 currentLanguages={currentLanguages}
1539 onSelectLanguage={onSelectLanguage}
1540 />
1541 <CharProgress
1542 count={post.shortenedGraphemeLength}
1543 style={{width: 65}}
1544 />
1545 </View>
1546 </View>
1547 )
1548}
1549
1550export function useComposerCancelRef() {
1551 return useRef<CancelRef>(null)
1552}
1553
1554function useScrollTracker({
1555 scrollViewRef,
1556 stickyBottom,
1557}: {
1558 scrollViewRef: AnimatedRef<Animated.ScrollView>
1559 stickyBottom: boolean
1560}) {
1561 const t = useTheme()
1562 const contentOffset = useSharedValue(0)
1563 const scrollViewHeight = useSharedValue(Infinity)
1564 const contentHeight = useSharedValue(0)
1565
1566 const hasScrolledToTop = useDerivedValue(() =>
1567 withTiming(contentOffset.get() === 0 ? 1 : 0),
1568 )
1569
1570 const hasScrolledToBottom = useDerivedValue(() =>
1571 withTiming(
1572 contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get()
1573 ? 1
1574 : 0,
1575 ),
1576 )
1577
1578 const showHideBottomBorder = useCallback(
1579 ({
1580 newContentHeight,
1581 newContentOffset,
1582 newScrollViewHeight,
1583 }: {
1584 newContentHeight?: number
1585 newContentOffset?: number
1586 newScrollViewHeight?: number
1587 }) => {
1588 'worklet'
1589 if (typeof newContentHeight === 'number')
1590 contentHeight.set(Math.floor(newContentHeight))
1591 if (typeof newContentOffset === 'number')
1592 contentOffset.set(Math.floor(newContentOffset))
1593 if (typeof newScrollViewHeight === 'number')
1594 scrollViewHeight.set(Math.floor(newScrollViewHeight))
1595 },
1596 [contentHeight, contentOffset, scrollViewHeight],
1597 )
1598
1599 const scrollHandler = useAnimatedScrollHandler({
1600 onScroll: event => {
1601 'worklet'
1602 showHideBottomBorder({
1603 newContentOffset: event.contentOffset.y,
1604 newContentHeight: event.contentSize.height,
1605 newScrollViewHeight: event.layoutMeasurement.height,
1606 })
1607 },
1608 })
1609
1610 const onScrollViewContentSizeChangeUIThread = useCallback(
1611 (newContentHeight: number) => {
1612 'worklet'
1613 const oldContentHeight = contentHeight.get()
1614 let shouldScrollToBottom = false
1615 if (stickyBottom && newContentHeight > oldContentHeight) {
1616 const isFairlyCloseToBottom =
1617 oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get()
1618 if (isFairlyCloseToBottom) {
1619 shouldScrollToBottom = true
1620 }
1621 }
1622 showHideBottomBorder({newContentHeight})
1623 if (shouldScrollToBottom) {
1624 scrollTo(scrollViewRef, 0, newContentHeight, true)
1625 }
1626 },
1627 [
1628 showHideBottomBorder,
1629 scrollViewRef,
1630 contentHeight,
1631 stickyBottom,
1632 contentOffset,
1633 scrollViewHeight,
1634 ],
1635 )
1636
1637 const onScrollViewContentSizeChange = useCallback(
1638 (_width: number, height: number) => {
1639 runOnUI(onScrollViewContentSizeChangeUIThread)(height)
1640 },
1641 [onScrollViewContentSizeChangeUIThread],
1642 )
1643
1644 const onScrollViewLayout = useCallback(
1645 (evt: LayoutChangeEvent) => {
1646 showHideBottomBorder({
1647 newScrollViewHeight: evt.nativeEvent.layout.height,
1648 })
1649 },
1650 [showHideBottomBorder],
1651 )
1652
1653 const topBarAnimatedStyle = useAnimatedStyle(() => {
1654 return {
1655 borderBottomWidth: StyleSheet.hairlineWidth,
1656 borderColor: interpolateColor(
1657 hasScrolledToTop.get(),
1658 [0, 1],
1659 [t.atoms.border_contrast_medium.borderColor, 'transparent'],
1660 ),
1661 }
1662 })
1663 const bottomBarAnimatedStyle = useAnimatedStyle(() => {
1664 return {
1665 borderTopWidth: StyleSheet.hairlineWidth,
1666 borderColor: interpolateColor(
1667 hasScrolledToBottom.get(),
1668 [0, 1],
1669 [t.atoms.border_contrast_medium.borderColor, 'transparent'],
1670 ),
1671 }
1672 })
1673
1674 return {
1675 scrollHandler,
1676 onScrollViewContentSizeChange,
1677 onScrollViewLayout,
1678 topBarAnimatedStyle,
1679 bottomBarAnimatedStyle,
1680 }
1681}
1682
1683function useKeyboardVerticalOffset() {
1684 const {top, bottom} = useSafeAreaInsets()
1685
1686 // Android etc
1687 if (!isIOS) {
1688 // need to account for the edge-to-edge nav bar
1689 return bottom * -1
1690 }
1691
1692 // iPhone SE
1693 if (top === 20) return 40
1694
1695 // all other iPhones
1696 return top + 10
1697}
1698
1699async function whenAppViewReady(
1700 agent: BskyAgent,
1701 uri: string,
1702 fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
1703) {
1704 await until(
1705 5, // 5 tries
1706 1e3, // 1s delay between tries
1707 fn,
1708 () =>
1709 agent.app.bsky.unspecced.getPostThreadV2({
1710 anchor: uri,
1711 above: false,
1712 below: 0,
1713 branchingFactor: 0,
1714 }),
1715 )
1716}
1717
1718function isEmptyPost(post: PostDraft) {
1719 return (
1720 post.richtext.text.trim().length === 0 &&
1721 !post.embed.media &&
1722 !post.embed.link &&
1723 !post.embed.quote
1724 )
1725}
1726
1727function useHideKeyboardOnBackground() {
1728 const appState = useAppState()
1729
1730 useEffect(() => {
1731 if (isIOS) {
1732 if (appState === 'inactive') {
1733 Keyboard.dismiss()
1734 }
1735 }
1736 }, [appState])
1737}
1738
1739const styles = StyleSheet.create({
1740 topbarInner: {
1741 flexDirection: 'row',
1742 alignItems: 'center',
1743 paddingHorizontal: 8,
1744 height: 54,
1745 gap: 4,
1746 },
1747 postBtn: {
1748 borderRadius: 20,
1749 paddingHorizontal: 20,
1750 paddingVertical: 6,
1751 marginLeft: 12,
1752 },
1753 stickyFooterWeb: web({
1754 position: 'sticky',
1755 bottom: 0,
1756 }),
1757 errorLine: {
1758 flexDirection: 'row',
1759 alignItems: 'center',
1760 backgroundColor: colors.red1,
1761 borderRadius: 6,
1762 marginHorizontal: 16,
1763 paddingHorizontal: 12,
1764 paddingVertical: 10,
1765 marginBottom: 8,
1766 },
1767 reminderLine: {
1768 flexDirection: 'row',
1769 alignItems: 'center',
1770 borderRadius: 6,
1771 marginHorizontal: 16,
1772 paddingHorizontal: 8,
1773 paddingVertical: 6,
1774 marginBottom: 8,
1775 },
1776 errorIcon: {
1777 borderWidth: StyleSheet.hairlineWidth,
1778 borderColor: colors.red4,
1779 color: colors.red4,
1780 borderRadius: 30,
1781 width: 16,
1782 height: 16,
1783 alignItems: 'center',
1784 justifyContent: 'center',
1785 marginRight: 5,
1786 },
1787 inactivePost: {
1788 opacity: 0.5,
1789 },
1790 addExtLinkBtn: {
1791 borderWidth: 1,
1792 borderRadius: 24,
1793 paddingHorizontal: 16,
1794 paddingVertical: 12,
1795 marginHorizontal: 10,
1796 marginBottom: 4,
1797 },
1798})
1799
1800function ErrorBanner({
1801 error: standardError,
1802 videoState,
1803 clearError,
1804 clearVideo,
1805}: {
1806 error: string
1807 videoState: VideoState | NoVideoState
1808 clearError: () => void
1809 clearVideo: () => void
1810}) {
1811 const t = useTheme()
1812 const {_} = useLingui()
1813
1814 const enableSquareButtons = useEnableSquareButtons()
1815
1816 const videoError =
1817 videoState.status === 'error' ? videoState.error : undefined
1818 const error = standardError || videoError
1819
1820 const onClearError = () => {
1821 if (standardError) {
1822 clearError()
1823 } else {
1824 clearVideo()
1825 }
1826 }
1827
1828 if (!error) return null
1829
1830 return (
1831 <Animated.View
1832 style={[a.px_lg, a.pb_sm]}
1833 entering={FadeIn}
1834 exiting={FadeOut}>
1835 <View
1836 style={[
1837 a.px_md,
1838 a.py_sm,
1839 a.gap_xs,
1840 a.rounded_sm,
1841 t.atoms.bg_contrast_25,
1842 ]}>
1843 <View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
1844 <CircleInfoIcon fill={t.palette.negative_400} />
1845 <NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
1846 {error}
1847 </NewText>
1848 <Button
1849 label={_(msg`Dismiss error`)}
1850 size="tiny"
1851 color="secondary"
1852 variant="ghost"
1853 shape={enableSquareButtons ? 'square' : 'round'}
1854 style={[a.absolute, {top: 0, right: 0}]}
1855 onPress={onClearError}>
1856 <ButtonIcon icon={XIcon} />
1857 </Button>
1858 </View>
1859 {videoError && videoState.jobId && (
1860 <NewText
1861 style={[
1862 {paddingLeft: 28},
1863 a.text_xs,
1864 a.font_semi_bold,
1865 a.leading_snug,
1866 t.atoms.text_contrast_low,
1867 ]}>
1868 <Trans>Job ID: {videoState.jobId}</Trans>
1869 </NewText>
1870 )}
1871 </View>
1872 </Animated.View>
1873 )
1874}
1875
1876function ToolbarWrapper({
1877 style,
1878 children,
1879}: {
1880 style: StyleProp<ViewStyle>
1881 children: React.ReactNode
1882}) {
1883 if (isWeb) return children
1884 return (
1885 <Animated.View
1886 style={style}
1887 entering={FadeIn.duration(400)}
1888 exiting={FadeOut.duration(400)}>
1889 {children}
1890 </Animated.View>
1891 )
1892}
1893
1894function VideoUploadToolbar({state}: {state: VideoState}) {
1895 const t = useTheme()
1896 const {_} = useLingui()
1897 const progress = state.progress
1898 const shouldRotate =
1899 state.status === 'processing' && (progress === 0 || progress === 1)
1900 let wheelProgress = shouldRotate ? 0.33 : progress
1901
1902 const rotate = useDerivedValue(() => {
1903 if (shouldRotate) {
1904 return withRepeat(
1905 withTiming(360, {
1906 duration: 2500,
1907 easing: Easing.out(Easing.cubic),
1908 }),
1909 -1,
1910 )
1911 }
1912 return 0
1913 })
1914
1915 const animatedStyle = useAnimatedStyle(() => {
1916 return {
1917 transform: [{rotateZ: `${rotate.get()}deg`}],
1918 }
1919 })
1920
1921 let text = ''
1922
1923 switch (state.status) {
1924 case 'compressing':
1925 text = _(msg`Compressing video...`)
1926 break
1927 case 'uploading':
1928 text = _(msg`Uploading video...`)
1929 break
1930 case 'processing':
1931 text = _(msg`Processing video...`)
1932 break
1933 case 'error':
1934 text = _(msg`Error`)
1935 wheelProgress = 100
1936 break
1937 case 'done':
1938 text = _(msg`Video uploaded`)
1939 break
1940 }
1941
1942 return (
1943 <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
1944 <Animated.View style={[animatedStyle]}>
1945 <ProgressCircle
1946 size={30}
1947 borderWidth={1}
1948 borderColor={t.atoms.border_contrast_low.borderColor}
1949 color={
1950 state.status === 'error'
1951 ? t.palette.negative_500
1952 : t.palette.primary_500
1953 }
1954 progress={wheelProgress}
1955 />
1956 </Animated.View>
1957 <NewText style={[a.font_semi_bold, a.ml_sm]}>{text}</NewText>
1958 </ToolbarWrapper>
1959 )
1960}