Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

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