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