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

Configure Feed

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

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