mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at thread-bug 1773 lines 50 kB view raw
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}