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