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 2592 lines 76 kB view raw
1import { 2 Fragment, 3 memo, 4 useCallback, 5 useEffect, 6 useImperativeHandle, 7 useMemo, 8 useReducer, 9 useRef, 10 useState, 11} from 'react' 12import { 13 ActivityIndicator, 14 BackHandler, 15 Keyboard, 16 KeyboardAvoidingView, 17 type LayoutChangeEvent, 18 Pressable, 19 ScrollView, 20 type StyleProp, 21 StyleSheet, 22 View, 23 type ViewStyle, 24} from 'react-native' 25// @ts-expect-error no type definition 26import ProgressCircle from 'react-native-progress/Circle' 27import Animated, { 28 type AnimatedRef, 29 Easing, 30 FadeIn, 31 FadeOut, 32 interpolateColor, 33 LayoutAnimationConfig, 34 LinearTransition, 35 runOnUI, 36 scrollTo, 37 useAnimatedRef, 38 useAnimatedScrollHandler, 39 useAnimatedStyle, 40 useDerivedValue, 41 useSharedValue, 42 withRepeat, 43 withTiming, 44 ZoomIn, 45 ZoomOut, 46} from 'react-native-reanimated' 47import {useSafeAreaInsets} from 'react-native-safe-area-context' 48import * as FileSystem from 'expo-file-system' 49import {EncodingType, readAsStringAsync} from 'expo-file-system/legacy' 50import {type ImagePickerAsset} from 'expo-image-picker' 51import { 52 AppBskyDraftCreateDraft, 53 AppBskyUnspeccedDefs, 54 type AppBskyUnspeccedGetPostThreadV2, 55 AtUri, 56 type BskyAgent, 57 type RichText, 58} from '@atproto/api' 59import {msg, plural} from '@lingui/core/macro' 60import {useLingui} from '@lingui/react' 61import {Trans} from '@lingui/react/macro' 62import {useNavigation} from '@react-navigation/native' 63import {useQueryClient} from '@tanstack/react-query' 64 65import {generateAltText} from '#/lib/ai/generateAltText' 66import * as apilib from '#/lib/api/index' 67import {EmbeddingDisabledError} from '#/lib/api/resolve' 68import {useAppState} from '#/lib/appState' 69import {retry} from '#/lib/async/retry' 70import {until} from '#/lib/async/until' 71import { 72 DEFAULT_ALT_TEXT_AI_MODEL, 73 MAX_ALT_TEXT, 74 MAX_DRAFT_GRAPHEME_LENGTH, 75 MAX_GRAPHEME_LENGTH, 76 SUPPORTED_MIME_TYPES, 77 type SupportedMimeTypes, 78} from '#/lib/constants' 79import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 80import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 81import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 82import {mimeToExt} from '#/lib/media/video/util' 83import {useCallOnce} from '#/lib/once' 84import {type NavigationProp} from '#/lib/routes/types' 85import {cleanError} from '#/lib/strings/errors' 86import {colors} from '#/lib/styles' 87import {logger} from '#/logger' 88import {useDialogStateControlContext} from '#/state/dialogs' 89import {emitPostCreated} from '#/state/events' 90import { 91 type ComposerImage, 92 createComposerImage, 93 pasteImage, 94} from '#/state/gallery' 95import {useModalControls} from '#/state/modals' 96import {useRequireAltTextEnabled} from '#/state/preferences' 97import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 98import { 99 fromPostLanguages, 100 toPostLanguages, 101 useLanguagePrefs, 102 useLanguagePrefsApi, 103} from '#/state/preferences/languages' 104import { 105 useOpenRouterApiKey, 106 useOpenRouterConfigured, 107 useOpenRouterModel, 108} from '#/state/preferences/openrouter' 109import {usePreferencesQuery} from '#/state/queries/preferences' 110import {useProfileQuery} from '#/state/queries/profile' 111import {type Gif} from '#/state/queries/tenor' 112import {useAgent, useSession} from '#/state/session' 113import {useComposerControls} from '#/state/shell/composer' 114import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer' 115import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 116import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' 117import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton' 118import { 119 ExternalEmbedGif, 120 ExternalEmbedLink, 121} from '#/view/com/composer/ExternalEmbed' 122import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' 123import {GifAltTextDialog} from '#/view/com/composer/GifAltText' 124import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' 125import {Gallery} from '#/view/com/composer/photos/Gallery' 126import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' 127import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' 128import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' 129// TODO: Prevent naming components that coincide with RN primitives 130// due to linting false positives 131import {TextInput} from '#/view/com/composer/text-input/TextInput' 132import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' 133import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' 134import {VideoEmbedRedraft} from '#/view/com/composer/videos/VideoEmbedRedraft' 135import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' 136import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' 137import {UserAvatar} from '#/view/com/util/UserAvatar' 138import {atoms as a, native, useTheme, web} from '#/alf' 139import {Admonition} from '#/components/Admonition' 140import {Button, ButtonIcon, ButtonText} from '#/components/Button' 141import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 142import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' 143import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 144import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 145import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' 146import * as Prompt from '#/components/Prompt' 147import * as Toast from '#/components/Toast' 148import {Text} from '#/components/Typography' 149import {useAnalytics} from '#/analytics' 150import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 151import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 152import { 153 draftToComposerPosts, 154 extractLocalRefs, 155 type RestoredVideo, 156} from './drafts/state/api' 157import { 158 loadDraftMedia, 159 useCleanupPublishedDraftMutation, 160 useSaveDraftMutation, 161} from './drafts/state/queries' 162import {type DraftSummary} from './drafts/state/schema' 163import {revokeAllMediaUrls} from './drafts/state/storage' 164import {PostLanguageSelect} from './select-language/PostLanguageSelect' 165import { 166 type AssetType, 167 SelectMediaButton, 168 type SelectMediaButtonProps, 169} from './SelectMediaButton' 170import { 171 type ComposerAction, 172 composerReducer, 173 createComposerState, 174 type EmbedDraft, 175 MAX_IMAGES, 176 type PostAction, 177 type PostDraft, 178 type ThreadDraft, 179} from './state/composer' 180import { 181 NO_VIDEO, 182 type NoVideoState, 183 processVideo, 184 type VideoState, 185} from './state/video' 186import {type TextInputRef} from './text-input/TextInput.types' 187import {getVideoMetadata} from './videos/pickVideo' 188import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' 189 190type CancelRef = { 191 onPressCancel: () => void 192} 193 194type Props = ComposerOpts 195export const ComposePost = ({ 196 replyTo, 197 onPost, 198 onPostSuccess, 199 quote: initQuote, 200 mention: initMention, 201 openEmojiPicker, 202 text: initText, 203 imageUris: initImageUris, 204 videoUri: initVideoUri, 205 openGallery, 206 logContext, 207 cancelRef, 208}: Props & { 209 cancelRef?: React.RefObject<CancelRef | null> 210}) => { 211 const {currentAccount} = useSession() 212 const ax = useAnalytics() 213 const agent = useAgent() 214 const queryClient = useQueryClient() 215 const currentDid = currentAccount!.did 216 const {closeComposer} = useComposerControls() 217 const {_} = useLingui() 218 const requireAltTextEnabled = useRequireAltTextEnabled() 219 const langPrefs = useLanguagePrefs() 220 const setLangPrefs = useLanguagePrefsApi() 221 const textInput = useRef<TextInputRef>(null) 222 const discardPromptControl = Prompt.usePromptControl() 223 const {mutateAsync: saveDraft, isPending: _isSavingDraft} = 224 useSaveDraftMutation() 225 const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation() 226 const {closeAllDialogs} = useDialogStateControlContext() 227 const {closeAllModals} = useModalControls() 228 const {data: preferences} = usePreferencesQuery() 229 const navigation = useNavigation<NavigationProp>() 230 231 const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) 232 const [isPublishing, setIsPublishing] = useState(false) 233 const [publishingStage, setPublishingStage] = useState('') 234 const [error, setError] = useState('') 235 236 /** 237 * Track when a draft was created so we can measure draft age in metrics. 238 * Set when a draft is loaded via handleSelectDraft. 239 */ 240 const [loadedDraftCreatedAt, setLoadedDraftCreatedAt] = useState< 241 string | null 242 >(null) 243 244 /** 245 * A temporary local reference to a language suggestion that the user has 246 * accepted. This overrides the global post language preference, but is not 247 * stored permanently. 248 */ 249 const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState< 250 string | null 251 >(null) 252 253 /** 254 * The language(s) of the post being replied to. 255 */ 256 const [replyToLanguages, setReplyToLanguages] = useState<string[]>( 257 replyTo?.langs || [], 258 ) 259 260 /** 261 * The currently selected languages of the post. Prefer local temporary 262 * language suggestion over global lang prefs, if available. 263 */ 264 const currentLanguages = useMemo( 265 () => 266 acceptedLanguageSuggestion 267 ? [acceptedLanguageSuggestion] 268 : toPostLanguages(langPrefs.postLanguage), 269 [acceptedLanguageSuggestion, langPrefs.postLanguage], 270 ) 271 272 /** 273 * When the user selects a language from the composer language selector, 274 * clear any temporary language suggestions they may have selected 275 * previously, and any we might try to suggest to them. 276 */ 277 const onSelectLanguage = () => { 278 setAcceptedLanguageSuggestion(null) 279 setReplyToLanguages([]) 280 } 281 282 const [composerState, composerDispatch] = useReducer( 283 composerReducer, 284 createComposerState({ 285 initImageUris, 286 initQuoteUri: initQuote?.uri, 287 initText, 288 initMention, 289 initInteractionSettings: preferences?.postInteractionSettings, 290 initVideoUri, 291 }), 292 ) 293 294 const thread = composerState.thread 295 296 // Clear error when composer content changes, but only if all posts are 297 // back within the character limit. 298 const allPostsWithinLimit = thread.posts.every( 299 post => post.richtext.graphemeLength <= MAX_DRAFT_GRAPHEME_LENGTH, 300 ) 301 302 const activePost = thread.posts[composerState.activePostIndex] 303 const nextPost: PostDraft | undefined = 304 thread.posts[composerState.activePostIndex + 1] 305 const dispatch = useCallback( 306 (postAction: PostAction) => { 307 composerDispatch({ 308 type: 'update_post', 309 postId: activePost.id, 310 postAction, 311 }) 312 }, 313 [activePost.id], 314 ) 315 316 const selectVideo = useCallback( 317 (postId: string, asset: ImagePickerAsset) => { 318 const abortController = new AbortController() 319 composerDispatch({ 320 type: 'update_post', 321 postId: postId, 322 postAction: { 323 type: 'embed_add_video', 324 asset, 325 abortController, 326 }, 327 }) 328 processVideo( 329 asset, 330 videoAction => { 331 composerDispatch({ 332 type: 'update_post', 333 postId: postId, 334 postAction: { 335 type: 'embed_update_video', 336 videoAction, 337 }, 338 }) 339 }, 340 agent, 341 currentDid, 342 abortController.signal, 343 _, 344 ) 345 }, 346 [_, agent, currentDid, composerDispatch], 347 ) 348 349 const onInitVideo = useNonReactiveCallback(() => { 350 if (initVideoUri && !initVideoUri.blobRef) { 351 selectVideo(activePost.id, initVideoUri) 352 } 353 }) 354 355 useEffect(() => { 356 onInitVideo() 357 }, [onInitVideo]) 358 359 // Fire composer:open metric on mount 360 useCallOnce(() => { 361 ax.metric('composer:open', { 362 logContext: logContext ?? 'Other', 363 isReply: !!replyTo, 364 hasQuote: !!initQuote, 365 hasDraft: false, 366 }) 367 })() 368 369 const clearVideo = useCallback( 370 (postId: string) => { 371 composerDispatch({ 372 type: 'update_post', 373 postId: postId, 374 postAction: { 375 type: 'embed_remove_video', 376 }, 377 }) 378 }, 379 [composerDispatch], 380 ) 381 382 const restoreVideo = useCallback( 383 async (postId: string, videoInfo: RestoredVideo) => { 384 try { 385 logger.debug('restoring video from draft', { 386 postId, 387 videoUri: videoInfo.uri, 388 altText: videoInfo.altText, 389 captionCount: videoInfo.captions.length, 390 }) 391 392 let asset: ImagePickerAsset 393 394 if (IS_WEB) { 395 // Web: Convert blob URL to a File, then get video metadata (returns data URL) 396 const response = await fetch(videoInfo.uri) 397 const blob = await response.blob() 398 const file = new File([blob], 'restored-video', { 399 type: videoInfo.mimeType, 400 }) 401 asset = await getVideoMetadata(file) 402 } else { 403 let uri = videoInfo.uri 404 if (IS_ANDROID) { 405 // Android: expo-file-system double-encodes filenames with special chars. 406 // The file exists, but react-native-compressor's MediaMetadataRetriever 407 // can't handle the double-encoded URI. Copy to a temp file with a simple name. 408 const sourceFile = new FileSystem.File(videoInfo.uri) 409 const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}` 410 const tempFile = new FileSystem.File( 411 FileSystem.Paths.cache, 412 tempFileName, 413 ) 414 sourceFile.copy(tempFile) 415 logger.debug('restoreVideo: copied to temp file', { 416 source: videoInfo.uri, 417 temp: tempFile.uri, 418 }) 419 uri = tempFile.uri 420 } 421 asset = await getVideoMetadata(uri) 422 } 423 424 // Start video processing using existing flow 425 const abortController = new AbortController() 426 composerDispatch({ 427 type: 'update_post', 428 postId, 429 postAction: { 430 type: 'embed_add_video', 431 asset, 432 abortController, 433 }, 434 }) 435 436 // Restore alt text immediately 437 if (videoInfo.altText) { 438 composerDispatch({ 439 type: 'update_post', 440 postId, 441 postAction: { 442 type: 'embed_update_video', 443 videoAction: { 444 type: 'update_alt_text', 445 altText: videoInfo.altText, 446 signal: abortController.signal, 447 }, 448 }, 449 }) 450 } 451 452 // Restore captions (web only - captions use File objects) 453 if (IS_WEB && videoInfo.captions.length > 0) { 454 const captionTracks = videoInfo.captions.map(c => ({ 455 lang: c.lang, 456 file: new File([c.content], `caption-${c.lang}.vtt`, { 457 type: 'text/vtt', 458 }), 459 })) 460 composerDispatch({ 461 type: 'update_post', 462 postId, 463 postAction: { 464 type: 'embed_update_video', 465 videoAction: { 466 type: 'update_captions', 467 updater: () => captionTracks, 468 signal: abortController.signal, 469 }, 470 }, 471 }) 472 } 473 474 // Start video compression and upload 475 processVideo( 476 asset, 477 videoAction => { 478 composerDispatch({ 479 type: 'update_post', 480 postId, 481 postAction: { 482 type: 'embed_update_video', 483 videoAction, 484 }, 485 }) 486 }, 487 agent, 488 currentDid, 489 abortController.signal, 490 _, 491 ) 492 } catch (e) { 493 logger.error('Failed to restore video from draft', { 494 postId, 495 error: e, 496 }) 497 } 498 }, 499 [_, agent, currentDid, composerDispatch], 500 ) 501 502 const handleSelectDraft = useCallback( 503 async (draftSummary: DraftSummary) => { 504 logger.debug('loading draft for editing', { 505 draftId: draftSummary.id, 506 }) 507 508 // Load local media files for the draft 509 const {loadedMedia} = await loadDraftMedia(draftSummary.draft) 510 511 // Extract original localRefs for orphan detection on save 512 const originalLocalRefs = extractLocalRefs(draftSummary.draft) 513 514 logger.debug('draft loaded', { 515 draftId: draftSummary.id, 516 loadedMediaCount: loadedMedia.size, 517 originalLocalRefCount: originalLocalRefs.size, 518 }) 519 520 // Convert server draft to composer posts (videos returned separately) 521 const {posts, restoredVideos} = await draftToComposerPosts( 522 draftSummary.draft, 523 loadedMedia, 524 ) 525 526 // Dispatch restore action (this also sets draftId in state) 527 composerDispatch({ 528 type: 'restore_from_draft', 529 draftId: draftSummary.id, 530 posts, 531 threadgateAllow: draftSummary.draft.threadgateAllow, 532 postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules, 533 loadedMedia, 534 originalLocalRefs, 535 }) 536 537 // Track when the draft was created for metrics 538 setLoadedDraftCreatedAt(draftSummary.createdAt) 539 540 // Fire draft:load metric 541 const draftPosts = draftSummary.posts 542 const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime() 543 ax.metric('draft:load', { 544 draftAgeMs, 545 hasText: draftPosts.some(p => p.text.trim().length > 0), 546 hasImages: draftPosts.some(p => p.images && p.images.length > 0), 547 hasVideo: draftPosts.some(p => !!p.video), 548 hasGif: draftPosts.some(p => !!p.gif), 549 postCount: draftPosts.length, 550 }) 551 552 // Initiate video processing for any restored videos 553 // This is async but we don't await - videos process in the background 554 for (const [postIndex, videoInfo] of restoredVideos) { 555 const postId = posts[postIndex].id 556 restoreVideo(postId, videoInfo) 557 } 558 }, 559 [composerDispatch, restoreVideo, ax], 560 ) 561 562 const [publishOnUpload, setPublishOnUpload] = useState(false) 563 564 const onClose = useCallback(() => { 565 closeComposer() 566 clearThumbnailCache(queryClient) 567 revokeAllMediaUrls() 568 }, [closeComposer, queryClient]) 569 570 const getDraftSaveError = useCallback( 571 (e: unknown): string => { 572 if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) { 573 return _(msg`You've reached the maximum number of drafts`) 574 } 575 return _(msg`Failed to save draft`) 576 }, 577 [_], 578 ) 579 580 const validateDraftTextOrError = useCallback((): boolean => { 581 const tooLong = composerState.thread.posts.some( 582 post => post.richtext.graphemeLength > MAX_DRAFT_GRAPHEME_LENGTH, 583 ) 584 if (tooLong) { 585 setError( 586 _( 587 msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`, 588 ), 589 ) 590 return false 591 } 592 return true 593 }, [composerState.thread.posts, _]) 594 595 const handleSaveDraft = useCallback(async () => { 596 setError('') 597 if (!validateDraftTextOrError()) { 598 return 599 } 600 const isNewDraft = !composerState.draftId 601 try { 602 const result = await saveDraft({ 603 composerState, 604 existingDraftId: composerState.draftId, 605 }) 606 composerDispatch({type: 'mark_saved', draftId: result.draftId}) 607 608 // Fire draft:save metric 609 const posts = composerState.thread.posts 610 ax.metric('draft:save', { 611 isNewDraft, 612 hasText: posts.some(p => p.richtext.text.trim().length > 0), 613 hasImages: posts.some(p => p.embed.media?.type === 'images'), 614 hasVideo: posts.some(p => p.embed.media?.type === 'video'), 615 hasGif: posts.some(p => p.embed.media?.type === 'gif'), 616 hasQuote: posts.some(p => !!p.embed.quote), 617 hasLink: posts.some(p => !!p.embed.link), 618 postCount: posts.length, 619 textLength: posts[0].richtext.text.length, 620 }) 621 622 onClose() 623 } catch (e) { 624 logger.error('Failed to save draft', {error: e}) 625 setError(getDraftSaveError(e)) 626 } 627 }, [ 628 saveDraft, 629 composerState, 630 composerDispatch, 631 onClose, 632 ax, 633 validateDraftTextOrError, 634 getDraftSaveError, 635 ]) 636 637 // Save without closing - for use by DraftsButton 638 const saveCurrentDraft = useCallback(async (): Promise<{ 639 success: boolean 640 }> => { 641 setError('') 642 if (!validateDraftTextOrError()) { 643 return {success: false} 644 } 645 try { 646 const result = await saveDraft({ 647 composerState, 648 existingDraftId: composerState.draftId, 649 }) 650 composerDispatch({type: 'mark_saved', draftId: result.draftId}) 651 return {success: true} 652 } catch (e) { 653 setError(getDraftSaveError(e)) 654 return {success: false} 655 } 656 }, [ 657 saveDraft, 658 composerState, 659 composerDispatch, 660 validateDraftTextOrError, 661 getDraftSaveError, 662 ]) 663 664 // Handle discard action - fires metric and closes composer 665 const handleDiscard = useCallback(() => { 666 const posts = thread.posts 667 const hasContent = posts.some( 668 post => 669 post.richtext.text.trim().length > 0 || 670 post.embed.media || 671 post.embed.link, 672 ) 673 ax.metric('draft:discard', { 674 logContext: 'ComposerClose', 675 hadContent: hasContent, 676 textLength: posts[0].richtext.text.length, 677 }) 678 onClose() 679 }, [thread.posts, ax, onClose]) 680 681 // Check if composer is empty (no content to save) 682 const isComposerEmpty = useMemo(() => { 683 // Has multiple posts means it's not empty 684 if (thread.posts.length > 1) return false 685 686 const firstPost = thread.posts[0] 687 // Has text 688 if (firstPost.richtext.text.trim().length > 0) return false 689 // Has media 690 if (firstPost.embed.media) return false 691 // Has quote 692 if (firstPost.embed.quote) return false 693 // Has link 694 if (firstPost.embed.link) return false 695 696 return true 697 }, [thread.posts]) 698 699 // Clear the composer (discard current content) 700 const handleClearComposer = useCallback(() => { 701 composerDispatch({ 702 type: 'clear', 703 initInteractionSettings: preferences?.postInteractionSettings, 704 }) 705 }, [composerDispatch, preferences?.postInteractionSettings]) 706 707 const insets = useSafeAreaInsets() 708 const viewStyles = useMemo( 709 () => ({ 710 paddingTop: IS_ANDROID ? insets.top : 0, 711 paddingBottom: 712 // iOS - when keyboard is closed, keep the bottom bar in the safe area 713 (IS_IOS && !isKeyboardVisible) || 714 // Android - Android >=35 KeyboardAvoidingView adds double padding when 715 // keyboard is closed, so we subtract that in the offset and add it back 716 // here when the keyboard is open 717 (IS_ANDROID && isKeyboardVisible) 718 ? insets.bottom 719 : 0, 720 }), 721 [insets, isKeyboardVisible], 722 ) 723 724 const onPressCancel = useCallback(() => { 725 if (textInput.current?.maybeClosePopup()) { 726 return 727 } 728 729 const hasContent = thread.posts.some( 730 post => 731 post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link, 732 ) 733 734 // Show discard prompt if there's content AND either: 735 // - No draft is loaded (new composition) 736 // - Draft is loaded but has been modified 737 if (hasContent && (!composerState.draftId || composerState.isDirty)) { 738 closeAllDialogs() 739 Keyboard.dismiss() 740 discardPromptControl.open() 741 } else { 742 onClose() 743 } 744 }, [ 745 thread, 746 composerState.draftId, 747 composerState.isDirty, 748 closeAllDialogs, 749 discardPromptControl, 750 onClose, 751 ]) 752 753 useImperativeHandle(cancelRef, () => ({onPressCancel})) 754 755 // On Android, pressing Back should ask confirmation. 756 useEffect(() => { 757 if (!IS_ANDROID) { 758 return 759 } 760 const backHandler = BackHandler.addEventListener( 761 'hardwareBackPress', 762 () => { 763 if (closeAllDialogs() || closeAllModals()) { 764 return true 765 } 766 onPressCancel() 767 return true 768 }, 769 ) 770 return () => { 771 backHandler.remove() 772 } 773 }, [onPressCancel, closeAllDialogs, closeAllModals]) 774 775 const missingAltError = useMemo(() => { 776 if (!requireAltTextEnabled) { 777 return 778 } 779 for (let i = 0; i < thread.posts.length; i++) { 780 const media = thread.posts[i].embed.media 781 if (media) { 782 if (media.type === 'images' && media.images.some(img => !img.alt)) { 783 return _(msg`One or more images is missing alt text.`) 784 } 785 if (media.type === 'gif' && !media.alt) { 786 return _(msg`One or more GIFs is missing alt text.`) 787 } 788 if ( 789 media.type === 'video' && 790 media.video.status !== 'error' && 791 !media.video.altText 792 ) { 793 return _(msg`One or more videos is missing alt text.`) 794 } 795 } 796 } 797 }, [thread, requireAltTextEnabled, _]) 798 799 const canPost = 800 !missingAltError && 801 thread.posts.every( 802 post => 803 post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && 804 !isEmptyPost(post) && 805 !( 806 post.embed.media?.type === 'video' && 807 post.embed.media.video.status === 'error' 808 ), 809 ) 810 811 const onPressPublish = useCallback(async () => { 812 if (isPublishing) { 813 return 814 } 815 816 if (!canPost) { 817 return 818 } 819 820 if ( 821 thread.posts.some( 822 post => 823 post.embed.media?.type === 'video' && 824 post.embed.media.video.asset && 825 post.embed.media.video.status !== 'done', 826 ) 827 ) { 828 setPublishOnUpload(true) 829 return 830 } 831 832 setError('') 833 setIsPublishing(true) 834 835 let postUri: string | undefined 836 let postSuccessData: OnPostSuccessData 837 try { 838 logger.info(`composer: posting...`) 839 postUri = ( 840 await apilib.post( 841 agent, 842 queryClient, 843 { 844 thread, 845 replyTo: replyTo?.uri, 846 onStateChange: setPublishingStage, 847 langs: currentLanguages, 848 }, 849 { 850 highResolutionImages: ax.features.enabled( 851 ax.features.ImageUploadsHighResolution, 852 ), 853 }, 854 ) 855 ).uris[0] 856 857 /* 858 * Wait for app view to have received the post(s). If this fails, it's 859 * ok, because the post _was_ actually published above. 860 */ 861 try { 862 if (postUri) { 863 logger.info(`composer: waiting for app view`) 864 865 const posts = await retry( 866 5, 867 _e => true, 868 async () => { 869 const res = await agent.app.bsky.unspecced.getPostThreadV2({ 870 anchor: postUri!, 871 above: false, 872 below: thread.posts.length - 1, 873 branchingFactor: 1, 874 }) 875 if (res.data.thread.length !== thread.posts.length) { 876 throw new Error(`composer: app view is not ready`) 877 } 878 if ( 879 !res.data.thread.every(p => 880 AppBskyUnspeccedDefs.isThreadItemPost(p.value), 881 ) 882 ) { 883 throw new Error(`composer: app view returned non-post items`) 884 } 885 return res.data.thread 886 }, 887 1e3, 888 ) 889 postSuccessData = { 890 replyToUri: replyTo?.uri, 891 posts, 892 } 893 } 894 } catch (waitErr: any) { 895 logger.info(`composer: waiting for app view failed`, { 896 safeMessage: waitErr, 897 }) 898 } 899 } catch (e: any) { 900 logger.error(e, { 901 message: `Composer: create post failed`, 902 hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), 903 }) 904 905 let err = cleanError(e.message) 906 if (err.includes('not locate record')) { 907 err = _( 908 msg`We're sorry! The post you are replying to has been deleted.`, 909 ) 910 } else if (e instanceof EmbeddingDisabledError) { 911 err = _(msg`This post's author has disabled quote posts.`) 912 } 913 setError(err) 914 setIsPublishing(false) 915 return 916 } finally { 917 if (postUri) { 918 let index = 0 919 for (let post of thread.posts) { 920 ax.metric('post:create', { 921 imageCount: 922 post.embed.media?.type === 'images' 923 ? post.embed.media.images.length 924 : 0, 925 isReply: index > 0 || !!replyTo, 926 isPartOfThread: thread.posts.length > 1, 927 hasLink: !!post.embed.link, 928 hasQuote: !!post.embed.quote, 929 langs: fromPostLanguages(currentLanguages), 930 logContext: 'Composer', 931 }) 932 index++ 933 } 934 } 935 if (thread.posts.length > 1) { 936 ax.metric('thread:create', { 937 postCount: thread.posts.length, 938 isReply: !!replyTo, 939 }) 940 } 941 } 942 if (postUri && !replyTo) { 943 emitPostCreated() 944 } 945 // Clean up draft and its media after successful publish 946 if (composerState.draftId && composerState.originalLocalRefs) { 947 // Fire draft:post metric 948 if (loadedDraftCreatedAt) { 949 const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime() 950 ax.metric('draft:post', { 951 draftAgeMs, 952 wasEdited: composerState.isDirty, 953 }) 954 } 955 956 logger.debug('post published, cleaning up draft', { 957 draftId: composerState.draftId, 958 mediaFileCount: composerState.originalLocalRefs.size, 959 }) 960 cleanupPublishedDraft({ 961 draftId: composerState.draftId, 962 originalLocalRefs: composerState.originalLocalRefs, 963 }) 964 } 965 setLangPrefs.savePostLanguageToHistory() 966 if (initQuote) { 967 // We want to wait for the quote count to update before we call `onPost`, which will refetch data 968 whenAppViewReady(agent, initQuote.uri, res => { 969 const anchor = res.data.thread.at(0) 970 if ( 971 AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) && 972 anchor.value.post.quoteCount !== initQuote.quoteCount 973 ) { 974 onPost?.(postUri) 975 onPostSuccess?.(postSuccessData) 976 return true 977 } 978 return false 979 }) 980 } else { 981 onPost?.(postUri) 982 onPostSuccess?.(postSuccessData) 983 } 984 onClose() 985 setTimeout(() => { 986 Toast.show( 987 <Toast.Outer> 988 <Toast.Icon /> 989 <Toast.Text> 990 {thread.posts.length > 1 991 ? _(msg`Your posts were sent`) 992 : replyTo 993 ? _(msg`Your reply was sent`) 994 : _(msg`Your post was sent`)} 995 </Toast.Text> 996 {postUri && ( 997 <Toast.Action 998 label={_(msg`View post`)} 999 onPress={() => { 1000 const {host: name, rkey} = new AtUri(postUri) 1001 navigation.navigate('PostThread', {name, rkey}) 1002 }}> 1003 <Trans context="Action to view the post the user just created"> 1004 View 1005 </Trans> 1006 </Toast.Action> 1007 )} 1008 </Toast.Outer>, 1009 {type: 'success'}, 1010 ) 1011 }, 500) 1012 }, [ 1013 _, 1014 ax, 1015 agent, 1016 thread, 1017 canPost, 1018 isPublishing, 1019 currentLanguages, 1020 onClose, 1021 onPost, 1022 onPostSuccess, 1023 initQuote, 1024 replyTo, 1025 setLangPrefs, 1026 queryClient, 1027 navigation, 1028 composerState.draftId, 1029 composerState.originalLocalRefs, 1030 composerState.isDirty, 1031 cleanupPublishedDraft, 1032 loadedDraftCreatedAt, 1033 ]) 1034 1035 // Preserves the referential identity passed to each post item. 1036 // Avoids re-rendering all posts on each keystroke. 1037 const onComposerPostPublish = useNonReactiveCallback(() => { 1038 onPressPublish() 1039 }) 1040 1041 useEffect(() => { 1042 if (publishOnUpload) { 1043 let erroredVideos = 0 1044 let uploadingVideos = 0 1045 for (let post of thread.posts) { 1046 if (post.embed.media?.type === 'video') { 1047 const video = post.embed.media.video 1048 if (video.status === 'error') { 1049 erroredVideos++ 1050 } else if (video.status !== 'done') { 1051 uploadingVideos++ 1052 } 1053 } 1054 } 1055 if (erroredVideos > 0) { 1056 setPublishOnUpload(false) 1057 } else if (uploadingVideos === 0) { 1058 setPublishOnUpload(false) 1059 onPressPublish() 1060 } 1061 } 1062 }, [thread.posts, onPressPublish, publishOnUpload]) 1063 1064 // TODO: It might make more sense to display this error per-post. 1065 // Right now we're just displaying the first one. 1066 let erroredVideoPostId: string | undefined 1067 let erroredVideo: VideoState | NoVideoState = NO_VIDEO 1068 for (let i = 0; i < thread.posts.length; i++) { 1069 const post = thread.posts[i] 1070 if ( 1071 post.embed.media?.type === 'video' && 1072 post.embed.media.video.status === 'error' 1073 ) { 1074 erroredVideoPostId = post.id 1075 erroredVideo = post.embed.media.video 1076 break 1077 } 1078 } 1079 1080 const onEmojiButtonPress = useCallback(() => { 1081 const rect = textInput.current?.getCursorPosition() 1082 if (rect) { 1083 openEmojiPicker?.({ 1084 ...rect, 1085 nextFocusRef: 1086 textInput as unknown as React.MutableRefObject<HTMLElement>, 1087 }) 1088 } 1089 }, [openEmojiPicker]) 1090 1091 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 1092 useEffect(() => { 1093 if (composerState.mutableNeedsFocusActive) { 1094 composerState.mutableNeedsFocusActive = false 1095 // On Android, this risks getting the cursor stuck behind the keyboard. 1096 // Not worth it. 1097 if (!IS_ANDROID) { 1098 textInput.current?.focus() 1099 } 1100 } 1101 }, [composerState]) 1102 1103 const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined 1104 const { 1105 scrollHandler, 1106 onScrollViewContentSizeChange, 1107 onScrollViewLayout, 1108 topBarAnimatedStyle, 1109 bottomBarAnimatedStyle, 1110 } = useScrollTracker({ 1111 scrollViewRef, 1112 stickyBottom: isLastThreadedPost, 1113 }) 1114 1115 const keyboardVerticalOffset = useKeyboardVerticalOffset() 1116 1117 const footer = ( 1118 <> 1119 <SuggestedLanguage 1120 text={activePost.richtext.text} 1121 replyToLanguages={replyToLanguages} 1122 currentLanguages={currentLanguages} 1123 onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion} 1124 /> 1125 <ComposerPills 1126 isReply={!!replyTo} 1127 post={activePost} 1128 thread={composerState.thread} 1129 dispatch={composerDispatch} 1130 bottomBarAnimatedStyle={bottomBarAnimatedStyle} 1131 /> 1132 <ComposerFooter 1133 post={activePost} 1134 dispatch={dispatch} 1135 showAddButton={ 1136 !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) 1137 } 1138 onError={setError} 1139 onEmojiButtonPress={onEmojiButtonPress} 1140 onSelectVideo={selectVideo} 1141 onAddPost={() => { 1142 composerDispatch({ 1143 type: 'add_post', 1144 }) 1145 }} 1146 currentLanguages={currentLanguages} 1147 onSelectLanguage={onSelectLanguage} 1148 openGallery={openGallery} 1149 /> 1150 </> 1151 ) 1152 1153 const IS_WEBFooterSticky = !IS_NATIVE && thread.posts.length > 1 1154 return ( 1155 <BottomSheetPortalProvider> 1156 <KeyboardAvoidingView 1157 testID="composePostView" 1158 behavior={IS_IOS ? 'padding' : 'height'} 1159 keyboardVerticalOffset={keyboardVerticalOffset} 1160 style={a.flex_1}> 1161 <View 1162 style={[a.flex_1, viewStyles]} 1163 aria-modal 1164 accessibilityViewIsModal> 1165 <ComposerTopBar 1166 canPost={canPost} 1167 isReply={!!replyTo} 1168 isPublishQueued={publishOnUpload} 1169 isPublishing={isPublishing} 1170 isThread={thread.posts.length > 1} 1171 publishingStage={publishingStage} 1172 topBarAnimatedStyle={topBarAnimatedStyle} 1173 onCancel={onPressCancel} 1174 onPublish={onPressPublish} 1175 onSelectDraft={handleSelectDraft} 1176 onSaveDraft={saveCurrentDraft} 1177 onDiscard={handleClearComposer} 1178 isEmpty={isComposerEmpty} 1179 isDirty={composerState.isDirty} 1180 isEditingDraft={!!composerState.draftId} 1181 canSaveDraft={allPostsWithinLimit} 1182 textLength={thread.posts[0].richtext.text.length}> 1183 {missingAltError && ( 1184 <AltTextReminder 1185 error={missingAltError} 1186 thread={thread} 1187 dispatch={composerDispatch} 1188 /> 1189 )} 1190 <ErrorBanner 1191 error={error} 1192 videoState={erroredVideo} 1193 clearError={() => setError('')} 1194 clearVideo={ 1195 erroredVideoPostId 1196 ? () => clearVideo(erroredVideoPostId) 1197 : () => {} 1198 } 1199 /> 1200 </ComposerTopBar> 1201 1202 <Animated.ScrollView 1203 ref={scrollViewRef} 1204 layout={native(LinearTransition)} 1205 onScroll={scrollHandler} 1206 contentContainerStyle={a.flex_grow} 1207 style={a.flex_1} 1208 keyboardShouldPersistTaps="always" 1209 onContentSizeChange={onScrollViewContentSizeChange} 1210 onLayout={onScrollViewLayout}> 1211 {replyTo && replyTo.text && replyTo.author ? ( 1212 <ComposerReplyTo replyTo={replyTo} /> 1213 ) : undefined} 1214 {thread.posts.map((post, index) => ( 1215 <Fragment key={post.id + (composerState.draftId ?? '')}> 1216 <ComposerPost 1217 post={post} 1218 dispatch={composerDispatch} 1219 textInput={post.id === activePost.id ? textInput : null} 1220 isFirstPost={index === 0} 1221 isLastPost={index === thread.posts.length - 1} 1222 isPartOfThread={thread.posts.length > 1} 1223 isReply={index > 0 || !!replyTo} 1224 isActive={post.id === activePost.id} 1225 canRemovePost={thread.posts.length > 1} 1226 canRemoveQuote={index > 0 || !initQuote} 1227 onSelectVideo={selectVideo} 1228 onClearVideo={clearVideo} 1229 onPublish={onComposerPostPublish} 1230 onError={setError} 1231 /> 1232 {IS_WEBFooterSticky && post.id === activePost.id && ( 1233 <View style={styles.stickyFooterWeb}>{footer}</View> 1234 )} 1235 </Fragment> 1236 ))} 1237 </Animated.ScrollView> 1238 {!IS_WEBFooterSticky && footer} 1239 </View> 1240 1241 {replyTo ? ( 1242 <Prompt.Basic 1243 control={discardPromptControl} 1244 title={_(msg`Discard draft?`)} 1245 description="" 1246 confirmButtonCta={_(msg`Discard`)} 1247 confirmButtonColor="negative" 1248 onConfirm={handleDiscard} 1249 /> 1250 ) : ( 1251 <Prompt.Outer control={discardPromptControl}> 1252 <Prompt.Content> 1253 <Prompt.TitleText> 1254 {allPostsWithinLimit ? ( 1255 composerState.draftId ? ( 1256 <Trans>Save changes?</Trans> 1257 ) : ( 1258 <Trans>Save draft?</Trans> 1259 ) 1260 ) : ( 1261 <Trans>Discard post?</Trans> 1262 )} 1263 </Prompt.TitleText> 1264 <Prompt.DescriptionText> 1265 {allPostsWithinLimit ? ( 1266 composerState.draftId ? ( 1267 <Trans> 1268 You have unsaved changes to this draft, would you like to 1269 save them? 1270 </Trans> 1271 ) : ( 1272 <Trans> 1273 Would you like to save this as a draft to edit later? 1274 </Trans> 1275 ) 1276 ) : ( 1277 <Trans>You can only save drafts up to 1000 characters.</Trans> 1278 )} 1279 </Prompt.DescriptionText> 1280 </Prompt.Content> 1281 <Prompt.Actions> 1282 {allPostsWithinLimit && ( 1283 <Prompt.Action 1284 cta={ 1285 composerState.draftId 1286 ? _(msg`Save changes`) 1287 : _(msg`Save draft`) 1288 } 1289 onPress={handleSaveDraft} 1290 color="primary" 1291 /> 1292 )} 1293 <Prompt.Action 1294 cta={_(msg`Discard`)} 1295 onPress={handleDiscard} 1296 color="negative_subtle" 1297 /> 1298 <Prompt.Cancel cta={_(msg`Keep editing`)} /> 1299 </Prompt.Actions> 1300 </Prompt.Outer> 1301 )} 1302 </KeyboardAvoidingView> 1303 </BottomSheetPortalProvider> 1304 ) 1305} 1306 1307let ComposerPost = memo(function ComposerPost({ 1308 post, 1309 dispatch, 1310 textInput, 1311 isActive, 1312 isReply, 1313 isFirstPost, 1314 isLastPost, 1315 isPartOfThread, 1316 canRemovePost, 1317 canRemoveQuote, 1318 onClearVideo, 1319 onSelectVideo, 1320 onError, 1321 onPublish, 1322}: { 1323 post: PostDraft 1324 dispatch: (action: ComposerAction) => void 1325 textInput: React.Ref<TextInputRef> 1326 isActive: boolean 1327 isReply: boolean 1328 isFirstPost: boolean 1329 isLastPost: boolean 1330 isPartOfThread: boolean 1331 canRemovePost: boolean 1332 canRemoveQuote: boolean 1333 onClearVideo: (postId: string) => void 1334 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1335 onError: (error: string) => void 1336 onPublish: (richtext: RichText) => void 1337}) { 1338 const {currentAccount} = useSession() 1339 const currentDid = currentAccount!.did 1340 const {_} = useLingui() 1341 const {data: currentProfile} = useProfileQuery({did: currentDid}) 1342 const richtext = post.richtext 1343 const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media 1344 const forceMinHeight = IS_WEB && isTextOnly && isActive 1345 const selectTextInputPlaceholder = isReply 1346 ? isFirstPost 1347 ? _(msg`Write your reply`) 1348 : _(msg`Add another post`) 1349 : _(msg`Anything but skeet`) 1350 const discardPromptControl = Prompt.usePromptControl() 1351 1352 const enableSquareButtons = useEnableSquareButtons() 1353 1354 const dispatchPost = useCallback( 1355 (action: PostAction) => { 1356 dispatch({ 1357 type: 'update_post', 1358 postId: post.id, 1359 postAction: action, 1360 }) 1361 }, 1362 [dispatch, post.id], 1363 ) 1364 1365 const onImageAdd = useCallback( 1366 (next: ComposerImage[]) => { 1367 dispatchPost({ 1368 type: 'embed_add_images', 1369 images: next, 1370 }) 1371 }, 1372 [dispatchPost], 1373 ) 1374 1375 const onNewLink = useCallback( 1376 (uri: string) => { 1377 dispatchPost({type: 'embed_add_uri', uri}) 1378 }, 1379 [dispatchPost], 1380 ) 1381 1382 const onPhotoPasted = useCallback( 1383 async (uri: string) => { 1384 if ( 1385 uri.startsWith('data:video/') || 1386 (IS_WEB && uri.startsWith('data:image/gif')) 1387 ) { 1388 if (IS_NATIVE) return // web only 1389 const [mimeType] = uri.slice('data:'.length).split(';') 1390 if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { 1391 Toast.show(_(msg`Unsupported video type: ${mimeType}`), { 1392 type: 'error', 1393 }) 1394 return 1395 } 1396 const name = `pasted.${mimeToExt(mimeType)}` 1397 const file = await fetch(uri) 1398 .then(res => res.blob()) 1399 .then(blob => new File([blob], name, {type: mimeType})) 1400 onSelectVideo(post.id, await getVideoMetadata(file)) 1401 } else { 1402 const res = await pasteImage(uri) 1403 onImageAdd([res]) 1404 } 1405 }, 1406 [post.id, onSelectVideo, onImageAdd, _], 1407 ) 1408 1409 useHideKeyboardOnBackground() 1410 1411 return ( 1412 <View 1413 style={[ 1414 a.mx_lg, 1415 a.mb_sm, 1416 !isActive && isLastPost && a.mb_lg, 1417 !isActive && styles.inactivePost, 1418 isTextOnly && isLastPost && IS_NATIVE && a.flex_grow, 1419 ]}> 1420 <View style={[a.flex_row, IS_NATIVE && a.flex_1]}> 1421 <UserAvatar 1422 avatar={currentProfile?.avatar} 1423 size={42} 1424 type={currentProfile?.associated?.labeler ? 'labeler' : 'user'} 1425 style={[a.mt_xs]} 1426 /> 1427 <TextInput 1428 ref={textInput} 1429 style={[a.pt_xs]} 1430 richtext={richtext} 1431 placeholder={selectTextInputPlaceholder} 1432 autoFocus={isLastPost} 1433 webForceMinHeight={forceMinHeight} 1434 // To avoid overlap with the close button: 1435 hasRightPadding={isPartOfThread} 1436 isActive={isActive} 1437 setRichText={rt => { 1438 dispatchPost({type: 'update_richtext', richtext: rt}) 1439 }} 1440 onFocus={() => { 1441 dispatch({ 1442 type: 'focus_post', 1443 postId: post.id, 1444 }) 1445 }} 1446 onPhotoPasted={onPhotoPasted} 1447 onNewLink={onNewLink} 1448 onError={onError} 1449 onPressPublish={onPublish} 1450 accessible={true} 1451 accessibilityLabel={_(msg`Write post`)} 1452 accessibilityHint={_( 1453 msg`Compose posts up to ${plural(MAX_GRAPHEME_LENGTH || 0, { 1454 other: '# characters', 1455 })} in length`, 1456 )} 1457 /> 1458 </View> 1459 1460 {canRemovePost && isActive && ( 1461 <> 1462 <Button 1463 label={_(msg`Delete post`)} 1464 size="small" 1465 color="secondary" 1466 variant="ghost" 1467 shape={enableSquareButtons ? 'square' : 'round'} 1468 style={[a.absolute, {top: 0, right: 0}]} 1469 onPress={() => { 1470 if ( 1471 post.shortenedGraphemeLength > 0 || 1472 post.embed.media || 1473 post.embed.link || 1474 post.embed.quote 1475 ) { 1476 discardPromptControl.open() 1477 } else { 1478 dispatch({ 1479 type: 'remove_post', 1480 postId: post.id, 1481 }) 1482 } 1483 }}> 1484 <ButtonIcon icon={XIcon} /> 1485 </Button> 1486 <Prompt.Basic 1487 control={discardPromptControl} 1488 title={_(msg`Discard post?`)} 1489 description={_(msg`Are you sure you'd like to discard this post?`)} 1490 onConfirm={() => { 1491 dispatch({ 1492 type: 'remove_post', 1493 postId: post.id, 1494 }) 1495 }} 1496 confirmButtonCta={_(msg`Discard`)} 1497 confirmButtonColor="negative" 1498 /> 1499 </> 1500 )} 1501 1502 <ComposerEmbeds 1503 canRemoveQuote={canRemoveQuote} 1504 embed={post.embed} 1505 dispatch={dispatchPost} 1506 clearVideo={() => onClearVideo(post.id)} 1507 isActivePost={isActive} 1508 /> 1509 </View> 1510 ) 1511}) 1512 1513function ComposerTopBar({ 1514 canPost, 1515 isReply, 1516 isPublishQueued, 1517 isPublishing, 1518 isThread, 1519 publishingStage, 1520 onCancel, 1521 onPublish, 1522 onSelectDraft, 1523 onSaveDraft, 1524 onDiscard, 1525 isEmpty, 1526 isDirty, 1527 isEditingDraft, 1528 canSaveDraft, 1529 textLength, 1530 topBarAnimatedStyle, 1531 children, 1532}: { 1533 isPublishing: boolean 1534 publishingStage: string 1535 canPost: boolean 1536 isReply: boolean 1537 isPublishQueued: boolean 1538 isThread: boolean 1539 onCancel: () => void 1540 onPublish: () => void 1541 onSelectDraft: (draft: DraftSummary) => void 1542 onSaveDraft: () => Promise<{success: boolean}> 1543 onDiscard: () => void 1544 isEmpty: boolean 1545 isDirty: boolean 1546 isEditingDraft: boolean 1547 canSaveDraft: boolean 1548 textLength: number 1549 topBarAnimatedStyle: StyleProp<ViewStyle> 1550 children?: React.ReactNode 1551}) { 1552 const t = useTheme() 1553 const {_} = useLingui() 1554 1555 return ( 1556 <Animated.View 1557 style={topBarAnimatedStyle} 1558 layout={native(LinearTransition)}> 1559 <View 1560 style={[ 1561 a.flex_row, 1562 a.align_center, 1563 a.gap_xs, 1564 IS_LIQUID_GLASS ? [a.px_lg, a.pt_lg, a.pb_md] : [a.p_sm], 1565 ]}> 1566 <Button 1567 label={_(msg`Cancel`)} 1568 variant="ghost" 1569 color="primary" 1570 shape="default" 1571 size="small" 1572 style={[{paddingLeft: 7, paddingRight: 7}]} 1573 hoverStyle={[a.bg_transparent, {opacity: 0.5}]} 1574 onPress={onCancel} 1575 accessibilityHint={_( 1576 msg`Closes post composer and discards post draft`, 1577 )}> 1578 <ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}> 1579 <Trans>Cancel</Trans> 1580 </ButtonText> 1581 </Button> 1582 <View style={a.flex_1} /> 1583 {isPublishing ? ( 1584 <> 1585 <Text style={[t.atoms.text_contrast_medium]}> 1586 {publishingStage} 1587 </Text> 1588 <View style={styles.postBtn}> 1589 <ActivityIndicator color={t.palette.primary_500} /> 1590 </View> 1591 </> 1592 ) : ( 1593 <> 1594 {!isReply && ( 1595 <DraftsButton 1596 onSelectDraft={onSelectDraft} 1597 onSaveDraft={onSaveDraft} 1598 onDiscard={onDiscard} 1599 isEmpty={isEmpty} 1600 isDirty={isDirty} 1601 isEditingDraft={isEditingDraft} 1602 canSaveDraft={canSaveDraft} 1603 textLength={textLength} 1604 /> 1605 )} 1606 <Button 1607 testID="composerPublishBtn" 1608 label={ 1609 isReply 1610 ? isThread 1611 ? _( 1612 msg({ 1613 message: 'Publish replies', 1614 comment: 1615 'Accessibility label for button to publish multiple replies in a thread', 1616 }), 1617 ) 1618 : _( 1619 msg({ 1620 message: 'Publish reply', 1621 comment: 1622 'Accessibility label for button to publish a single reply', 1623 }), 1624 ) 1625 : isThread 1626 ? _( 1627 msg({ 1628 message: 'Publish posts', 1629 comment: 1630 'Accessibility label for button to publish multiple posts in a thread', 1631 }), 1632 ) 1633 : _( 1634 msg({ 1635 message: 'Publish post', 1636 comment: 1637 'Accessibility label for button to publish a single post', 1638 }), 1639 ) 1640 } 1641 color="primary" 1642 size="small" 1643 onPress={onPublish} 1644 disabled={!canPost || isPublishQueued}> 1645 <ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}> 1646 {isReply ? ( 1647 <Trans context="action">Reply</Trans> 1648 ) : isThread ? ( 1649 <Trans context="action">Post All</Trans> 1650 ) : ( 1651 <Trans context="action">Post</Trans> 1652 )} 1653 </ButtonText> 1654 </Button> 1655 </> 1656 )} 1657 </View> 1658 {children} 1659 </Animated.View> 1660 ) 1661} 1662 1663function AltTextReminder({ 1664 error, 1665 thread, 1666 dispatch, 1667}: { 1668 error: string 1669 thread: ThreadDraft 1670 dispatch: (action: ComposerAction) => void 1671}) { 1672 const {_} = useLingui() 1673 const t = useTheme() 1674 const openRouterConfigured = useOpenRouterConfigured() 1675 const openRouterApiKey = useOpenRouterApiKey() 1676 const openRouterModel = useOpenRouterModel() 1677 const [isGenerating, setIsGenerating] = useState(false) 1678 1679 const hasImagesWithoutAlt = useMemo(() => { 1680 for (const post of thread.posts) { 1681 const media = post.embed.media 1682 if (media?.type === 'images' && media.images.some(img => !img.alt)) { 1683 return true 1684 } 1685 } 1686 return false 1687 }, [thread]) 1688 1689 const handleGenerateAltText = useCallback(async () => { 1690 if (!openRouterApiKey) return 1691 1692 setIsGenerating(true) 1693 1694 try { 1695 for (const post of thread.posts) { 1696 const media = post.embed.media 1697 if (media?.type === 'images') { 1698 for (const image of media.images) { 1699 if (!image.alt) { 1700 try { 1701 const imagePath = (image.transformed ?? image.source).path 1702 1703 let base64: string 1704 let mimeType: string 1705 1706 if (IS_WEB) { 1707 const response = await fetch(imagePath) 1708 const blob = await response.blob() 1709 mimeType = blob.type || 'image/jpeg' 1710 const arrayBuffer = await blob.arrayBuffer() 1711 const uint8Array = new Uint8Array(arrayBuffer) 1712 let binary = '' 1713 for (let i = 0; i < uint8Array.length; i++) { 1714 binary += String.fromCharCode(uint8Array[i]) 1715 } 1716 base64 = btoa(binary) 1717 } else { 1718 const base64Result = await readAsStringAsync(imagePath, { 1719 encoding: EncodingType.Base64, 1720 }) 1721 base64 = base64Result 1722 const pathParts = imagePath.split('.') 1723 const ext = pathParts[pathParts.length - 1]?.toLowerCase() 1724 mimeType = ext === 'png' ? 'image/png' : 'image/jpeg' 1725 } 1726 1727 const generated = await generateAltText( 1728 openRouterApiKey, 1729 openRouterModel ?? DEFAULT_ALT_TEXT_AI_MODEL, 1730 base64, 1731 mimeType, 1732 ) 1733 1734 dispatch({ 1735 type: 'update_post', 1736 postId: post.id, 1737 postAction: { 1738 type: 'embed_update_image', 1739 image: { 1740 ...image, 1741 alt: generated.slice(0, MAX_ALT_TEXT), 1742 }, 1743 }, 1744 }) 1745 } catch (err) { 1746 logger.error('Failed to generate alt text for image', { 1747 error: err, 1748 }) 1749 } 1750 } 1751 } 1752 } 1753 } 1754 } finally { 1755 setIsGenerating(false) 1756 } 1757 }, [openRouterApiKey, openRouterModel, thread, dispatch]) 1758 1759 return ( 1760 <Admonition type="error" style={[a.mt_2xs, a.mb_sm, a.mx_lg]}> 1761 <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}> 1762 <Text style={[a.flex_1]}>{error}</Text> 1763 {openRouterConfigured && hasImagesWithoutAlt && ( 1764 <Pressable 1765 accessibilityRole="button" 1766 accessibilityLabel={_(msg`Generate Alt Text with AI`)} 1767 accessibilityHint="" 1768 onPress={handleGenerateAltText} 1769 disabled={isGenerating}> 1770 {isGenerating ? ( 1771 <ActivityIndicator size="small" color={t.palette.primary_500} /> 1772 ) : ( 1773 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 1774 <Trans>Generate with Ai</Trans> 1775 </Text> 1776 )} 1777 </Pressable> 1778 )} 1779 </View> 1780 </Admonition> 1781 ) 1782} 1783 1784function ComposerEmbeds({ 1785 embed, 1786 dispatch, 1787 clearVideo, 1788 canRemoveQuote, 1789 isActivePost, 1790}: { 1791 embed: EmbedDraft 1792 dispatch: (action: PostAction) => void 1793 clearVideo: () => void 1794 canRemoveQuote: boolean 1795 isActivePost: boolean 1796}) { 1797 const video = embed.media?.type === 'video' ? embed.media.video : null 1798 return ( 1799 <> 1800 {embed.media?.type === 'images' && ( 1801 <Gallery images={embed.media.images} dispatch={dispatch} /> 1802 )} 1803 1804 {embed.media?.type === 'gif' && ( 1805 <View style={[a.relative, a.mt_lg]} key={embed.media.gif.url}> 1806 <ExternalEmbedGif 1807 gif={embed.media.gif} 1808 onRemove={() => dispatch({type: 'embed_remove_gif'})} 1809 /> 1810 <GifAltTextDialog 1811 gif={embed.media.gif} 1812 altText={embed.media.alt ?? ''} 1813 onSubmit={(altText: string) => { 1814 dispatch({type: 'embed_update_gif', alt: altText}) 1815 }} 1816 /> 1817 </View> 1818 )} 1819 1820 {!embed.media && embed.link && ( 1821 <View style={[a.relative, a.mt_lg]} key={embed.link.uri}> 1822 <ExternalEmbedLink 1823 uri={embed.link.uri} 1824 hasQuote={!!embed.quote} 1825 onRemove={() => dispatch({type: 'embed_remove_link'})} 1826 /> 1827 </View> 1828 )} 1829 1830 <LayoutAnimationConfig skipExiting> 1831 {video && ( 1832 <Animated.View 1833 style={[a.w_full, a.mt_lg]} 1834 entering={native(ZoomIn)} 1835 exiting={native(ZoomOut)}> 1836 {video.asset && 1837 (video.status === 'compressing' ? ( 1838 <VideoTranscodeProgress 1839 asset={video.asset} 1840 progress={video.progress} 1841 clear={clearVideo} 1842 /> 1843 ) : video.video ? ( 1844 <VideoPreview 1845 asset={video.asset} 1846 video={video.video} 1847 isActivePost={isActivePost} 1848 clear={clearVideo} 1849 /> 1850 ) : null)} 1851 {!video.asset && 1852 video.status === 'done' && 1853 'playlistUri' in video && ( 1854 <View style={[a.relative, a.mt_lg]}> 1855 <VideoEmbedRedraft 1856 blobRef={video.pendingPublish?.blobRef as any} 1857 playlistUri={video.playlistUri} 1858 aspectRatio={video.redraftDimensions} 1859 onRemove={clearVideo} 1860 /> 1861 </View> 1862 )} 1863 <SubtitleDialogBtn 1864 defaultAltText={video.altText} 1865 saveAltText={altText => 1866 dispatch({ 1867 type: 'embed_update_video', 1868 videoAction: { 1869 type: 'update_alt_text', 1870 altText, 1871 signal: video.abortController.signal, 1872 }, 1873 }) 1874 } 1875 captions={video.captions} 1876 setCaptions={(updater: (captions: any[]) => any[]) => { 1877 dispatch({ 1878 type: 'embed_update_video', 1879 videoAction: { 1880 type: 'update_captions', 1881 updater, 1882 signal: video.abortController.signal, 1883 }, 1884 }) 1885 }} 1886 /> 1887 </Animated.View> 1888 )} 1889 </LayoutAnimationConfig> 1890 {embed.quote?.uri ? ( 1891 <View 1892 style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], IS_WEB && [a.pb_md]]}> 1893 <View style={[a.relative]}> 1894 <LazyQuoteEmbed uri={embed.quote.uri} linkDisabled /> 1895 {canRemoveQuote && ( 1896 <ExternalEmbedRemoveBtn 1897 onRemove={() => dispatch({type: 'embed_remove_quote'})} 1898 style={{top: 16}} 1899 /> 1900 )} 1901 </View> 1902 </View> 1903 ) : null} 1904 </> 1905 ) 1906} 1907 1908function ComposerPills({ 1909 isReply, 1910 thread, 1911 post, 1912 dispatch, 1913 bottomBarAnimatedStyle, 1914}: { 1915 isReply: boolean 1916 thread: ThreadDraft 1917 post: PostDraft 1918 dispatch: (action: ComposerAction) => void 1919 bottomBarAnimatedStyle: StyleProp<ViewStyle> 1920}) { 1921 const t = useTheme() 1922 const media = post.embed.media 1923 const hasMedia = media?.type === 'images' || media?.type === 'video' 1924 const hasLink = !!post.embed.link 1925 1926 // Don't render anything if no pills are going to be displayed 1927 if (isReply && !hasMedia && !hasLink) { 1928 return null 1929 } 1930 1931 return ( 1932 <Animated.View 1933 style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}> 1934 <ScrollView 1935 contentContainerStyle={[a.gap_sm]} 1936 horizontal={true} 1937 bounces={false} 1938 keyboardShouldPersistTaps="always" 1939 showsHorizontalScrollIndicator={false}> 1940 {isReply ? null : ( 1941 <ThreadgateBtn 1942 postgate={thread.postgate} 1943 onChangePostgate={nextPostgate => { 1944 dispatch({type: 'update_postgate', postgate: nextPostgate}) 1945 }} 1946 threadgateAllowUISettings={thread.threadgate} 1947 onChangeThreadgateAllowUISettings={nextThreadgate => { 1948 dispatch({ 1949 type: 'update_threadgate', 1950 threadgate: nextThreadgate, 1951 }) 1952 }} 1953 style={bottomBarAnimatedStyle} 1954 /> 1955 )} 1956 {hasMedia || hasLink ? ( 1957 <LabelsBtn 1958 labels={post.labels} 1959 onChange={nextLabels => { 1960 dispatch({ 1961 type: 'update_post', 1962 postId: post.id, 1963 postAction: { 1964 type: 'update_labels', 1965 labels: nextLabels, 1966 }, 1967 }) 1968 }} 1969 /> 1970 ) : null} 1971 </ScrollView> 1972 </Animated.View> 1973 ) 1974} 1975 1976function ComposerFooter({ 1977 post, 1978 dispatch, 1979 showAddButton, 1980 onEmojiButtonPress, 1981 onSelectVideo, 1982 onAddPost, 1983 currentLanguages, 1984 onSelectLanguage, 1985 openGallery, 1986}: { 1987 post: PostDraft 1988 dispatch: (action: PostAction) => void 1989 showAddButton: boolean 1990 onEmojiButtonPress: () => void 1991 onError: (error: string) => void 1992 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1993 onAddPost: () => void 1994 currentLanguages: string[] 1995 onSelectLanguage?: (language: string) => void 1996 openGallery?: boolean 1997}) { 1998 const t = useTheme() 1999 const {_} = useLingui() 2000 const {isMobile} = useWebMediaQueries() 2001 /* 2002 * Once we've allowed a certain type of asset to be selected, we don't allow 2003 * other types of media to be selected. 2004 */ 2005 const [selectedAssetsType, setSelectedAssetsType] = useState< 2006 AssetType | undefined 2007 >(undefined) 2008 2009 const media = post.embed.media 2010 const images = media?.type === 'images' ? media.images : [] 2011 const video = media?.type === 'video' ? media.video : null 2012 const isMaxImages = images.length >= MAX_IMAGES 2013 const isMaxVideos = !!video 2014 2015 let selectedAssetsCount = 0 2016 let isMediaSelectionDisabled = false 2017 2018 const enableSquareButtons = useEnableSquareButtons() 2019 2020 if (media?.type === 'images') { 2021 isMediaSelectionDisabled = isMaxImages 2022 selectedAssetsCount = images.length 2023 } else if (media?.type === 'video') { 2024 isMediaSelectionDisabled = isMaxVideos 2025 selectedAssetsCount = 1 2026 } else { 2027 isMediaSelectionDisabled = !!media 2028 } 2029 2030 const onImageAdd = useCallback( 2031 (next: ComposerImage[]) => { 2032 dispatch({ 2033 type: 'embed_add_images', 2034 images: next, 2035 }) 2036 }, 2037 [dispatch], 2038 ) 2039 2040 const onSelectGif = useCallback( 2041 (gif: Gif) => { 2042 dispatch({type: 'embed_add_gif', gif}) 2043 }, 2044 [dispatch], 2045 ) 2046 2047 /* 2048 * Reset if the user clears any selected media 2049 */ 2050 if (selectedAssetsType !== undefined && !media) { 2051 setSelectedAssetsType(undefined) 2052 } 2053 2054 const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>( 2055 async ({type, assets, errors}) => { 2056 setSelectedAssetsType(type) 2057 2058 if (assets.length) { 2059 if (type === 'image') { 2060 const selectedImages: ComposerImage[] = [] 2061 2062 await Promise.all( 2063 assets.map(async image => { 2064 const composerImage = await createComposerImage({ 2065 path: image.uri, 2066 width: image.width, 2067 height: image.height, 2068 mime: image.mimeType!, 2069 }) 2070 selectedImages.push(composerImage) 2071 }), 2072 ).catch(e => { 2073 logger.error(`createComposerImage failed`, { 2074 safeMessage: e.message, 2075 }) 2076 }) 2077 2078 onImageAdd(selectedImages) 2079 } else if (type === 'video') { 2080 onSelectVideo(post.id, assets[0]) 2081 } else if (type === 'gif') { 2082 onSelectVideo(post.id, assets[0]) 2083 } 2084 } 2085 2086 errors.map(error => { 2087 Toast.show(error, { 2088 type: 'warning', 2089 }) 2090 }) 2091 }, 2092 [post.id, onSelectVideo, onImageAdd], 2093 ) 2094 2095 return ( 2096 <View 2097 style={[ 2098 a.flex_row, 2099 a.py_xs, 2100 {paddingLeft: 7, paddingRight: 16}, 2101 a.align_center, 2102 a.border_t, 2103 t.atoms.bg, 2104 t.atoms.border_contrast_medium, 2105 a.justify_between, 2106 ]}> 2107 <View style={[a.flex_row, a.align_center]}> 2108 <LayoutAnimationConfig skipEntering skipExiting> 2109 {video && video.status !== 'done' ? ( 2110 <VideoUploadToolbar state={video} /> 2111 ) : ( 2112 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> 2113 <SelectMediaButton 2114 disabled={isMediaSelectionDisabled} 2115 allowedAssetTypes={selectedAssetsType} 2116 selectedAssetsCount={selectedAssetsCount} 2117 onSelectAssets={onSelectAssets} 2118 autoOpen={openGallery} 2119 /> 2120 <OpenCameraBtn 2121 disabled={media?.type === 'images' ? isMaxImages : !!media} 2122 onAdd={onImageAdd} 2123 /> 2124 <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> 2125 {!isMobile ? ( 2126 <Button 2127 onPress={onEmojiButtonPress} 2128 style={a.p_sm} 2129 label={_(msg`Open emoji picker`)} 2130 accessibilityHint={_(msg`Opens emoji picker`)} 2131 variant="ghost" 2132 shape={enableSquareButtons ? 'square' : 'round'} 2133 color="primary"> 2134 <EmojiSmileIcon size="lg" /> 2135 </Button> 2136 ) : null} 2137 </ToolbarWrapper> 2138 )} 2139 </LayoutAnimationConfig> 2140 </View> 2141 <View style={[a.flex_row, a.align_center, a.justify_between]}> 2142 {showAddButton && ( 2143 <Button 2144 label={_(msg`Add another post to thread`)} 2145 onPress={onAddPost} 2146 style={[a.p_sm]} 2147 variant="ghost" 2148 shape={enableSquareButtons ? 'square' : 'round'} 2149 color="primary"> 2150 <PlusIcon size="lg" /> 2151 </Button> 2152 )} 2153 <PostLanguageSelect 2154 currentLanguages={currentLanguages} 2155 onSelectLanguage={onSelectLanguage} 2156 /> 2157 <CharProgress 2158 count={post.shortenedGraphemeLength} 2159 style={{width: 65}} 2160 /> 2161 </View> 2162 </View> 2163 ) 2164} 2165 2166export function useComposerCancelRef() { 2167 return useRef<CancelRef>(null) 2168} 2169 2170function useScrollTracker({ 2171 scrollViewRef, 2172 stickyBottom, 2173}: { 2174 scrollViewRef: AnimatedRef<Animated.ScrollView> 2175 stickyBottom: boolean 2176}) { 2177 const t = useTheme() 2178 const contentOffset = useSharedValue(0) 2179 const scrollViewHeight = useSharedValue(Infinity) 2180 const contentHeight = useSharedValue(0) 2181 2182 const hasScrolledToTop = useDerivedValue(() => 2183 withTiming(contentOffset.get() === 0 ? 1 : 0), 2184 ) 2185 2186 const hasScrolledToBottom = useDerivedValue(() => 2187 withTiming( 2188 contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get() 2189 ? 1 2190 : 0, 2191 ), 2192 ) 2193 2194 const showHideBottomBorder = useCallback( 2195 ({ 2196 newContentHeight, 2197 newContentOffset, 2198 newScrollViewHeight, 2199 }: { 2200 newContentHeight?: number 2201 newContentOffset?: number 2202 newScrollViewHeight?: number 2203 }) => { 2204 'worklet' 2205 if (typeof newContentHeight === 'number') 2206 contentHeight.set(Math.floor(newContentHeight)) 2207 if (typeof newContentOffset === 'number') 2208 contentOffset.set(Math.floor(newContentOffset)) 2209 if (typeof newScrollViewHeight === 'number') 2210 scrollViewHeight.set(Math.floor(newScrollViewHeight)) 2211 }, 2212 [contentHeight, contentOffset, scrollViewHeight], 2213 ) 2214 2215 const scrollHandler = useAnimatedScrollHandler({ 2216 onScroll: event => { 2217 'worklet' 2218 showHideBottomBorder({ 2219 newContentOffset: event.contentOffset.y, 2220 newContentHeight: event.contentSize.height, 2221 newScrollViewHeight: event.layoutMeasurement.height, 2222 }) 2223 }, 2224 }) 2225 2226 const onScrollViewContentSizeChangeUIThread = useCallback( 2227 (newContentHeight: number) => { 2228 'worklet' 2229 const oldContentHeight = contentHeight.get() 2230 let shouldScrollToBottom = false 2231 if (stickyBottom && newContentHeight > oldContentHeight) { 2232 const isFairlyCloseToBottom = 2233 oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get() 2234 if (isFairlyCloseToBottom) { 2235 shouldScrollToBottom = true 2236 } 2237 } 2238 showHideBottomBorder({newContentHeight}) 2239 if (shouldScrollToBottom) { 2240 scrollTo(scrollViewRef, 0, newContentHeight, true) 2241 } 2242 }, 2243 [ 2244 showHideBottomBorder, 2245 scrollViewRef, 2246 contentHeight, 2247 stickyBottom, 2248 contentOffset, 2249 scrollViewHeight, 2250 ], 2251 ) 2252 2253 const onScrollViewContentSizeChange = useCallback( 2254 (_width: number, height: number) => { 2255 runOnUI(onScrollViewContentSizeChangeUIThread)(height) 2256 }, 2257 [onScrollViewContentSizeChangeUIThread], 2258 ) 2259 2260 const onScrollViewLayout = useCallback( 2261 (evt: LayoutChangeEvent) => { 2262 showHideBottomBorder({ 2263 newScrollViewHeight: evt.nativeEvent.layout.height, 2264 }) 2265 }, 2266 [showHideBottomBorder], 2267 ) 2268 2269 const topBarAnimatedStyle = useAnimatedStyle(() => { 2270 return { 2271 borderBottomWidth: StyleSheet.hairlineWidth, 2272 borderColor: interpolateColor( 2273 hasScrolledToTop.get(), 2274 [0, 1], 2275 [t.atoms.border_contrast_medium.borderColor, 'transparent'], 2276 ), 2277 } 2278 }) 2279 const bottomBarAnimatedStyle = useAnimatedStyle(() => { 2280 return { 2281 borderTopWidth: StyleSheet.hairlineWidth, 2282 borderColor: interpolateColor( 2283 hasScrolledToBottom.get(), 2284 [0, 1], 2285 [t.atoms.border_contrast_medium.borderColor, 'transparent'], 2286 ), 2287 } 2288 }) 2289 2290 return { 2291 scrollHandler, 2292 onScrollViewContentSizeChange, 2293 onScrollViewLayout, 2294 topBarAnimatedStyle, 2295 bottomBarAnimatedStyle, 2296 } 2297} 2298 2299function useKeyboardVerticalOffset() { 2300 const {top, bottom} = useSafeAreaInsets() 2301 2302 // Android etc 2303 if (!IS_IOS) { 2304 // need to account for the edge-to-edge nav bar 2305 return bottom * -1 2306 } 2307 2308 // they ditched the gap behaviour on 26 2309 if (IS_LIQUID_GLASS) { 2310 return top 2311 } 2312 2313 // iPhone SE 2314 if (top === 20) return 40 2315 2316 // all other iPhones on <26 2317 return top + 10 2318} 2319 2320async function whenAppViewReady( 2321 agent: BskyAgent, 2322 uri: string, 2323 fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean, 2324) { 2325 await until( 2326 5, // 5 tries 2327 1e3, // 1s delay between tries 2328 fn, 2329 () => 2330 agent.app.bsky.unspecced.getPostThreadV2({ 2331 anchor: uri, 2332 above: false, 2333 below: 0, 2334 branchingFactor: 0, 2335 }), 2336 ) 2337} 2338 2339function isEmptyPost(post: PostDraft) { 2340 return ( 2341 post.richtext.text.trim().length === 0 && 2342 !post.embed.media && 2343 !post.embed.link && 2344 !post.embed.quote 2345 ) 2346} 2347 2348function useHideKeyboardOnBackground() { 2349 const appState = useAppState() 2350 2351 useEffect(() => { 2352 if (IS_IOS) { 2353 if (appState === 'inactive') { 2354 Keyboard.dismiss() 2355 } 2356 } 2357 }, [appState]) 2358} 2359 2360const styles = StyleSheet.create({ 2361 postBtn: { 2362 borderRadius: 20, 2363 paddingHorizontal: 20, 2364 paddingVertical: 6, 2365 marginLeft: 12, 2366 }, 2367 stickyFooterWeb: web({ 2368 position: 'sticky', 2369 bottom: 0, 2370 }), 2371 errorLine: { 2372 flexDirection: 'row', 2373 alignItems: 'center', 2374 backgroundColor: colors.red1, 2375 borderRadius: 6, 2376 marginHorizontal: 16, 2377 paddingHorizontal: 12, 2378 paddingVertical: 10, 2379 marginBottom: 8, 2380 }, 2381 reminderLine: { 2382 flexDirection: 'row', 2383 alignItems: 'center', 2384 borderRadius: 6, 2385 marginHorizontal: 16, 2386 paddingHorizontal: 8, 2387 paddingVertical: 6, 2388 marginBottom: 8, 2389 }, 2390 errorIcon: { 2391 borderWidth: StyleSheet.hairlineWidth, 2392 borderColor: colors.red4, 2393 color: colors.red4, 2394 borderRadius: 30, 2395 width: 16, 2396 height: 16, 2397 alignItems: 'center', 2398 justifyContent: 'center', 2399 marginRight: 5, 2400 }, 2401 inactivePost: { 2402 opacity: 0.5, 2403 }, 2404 addExtLinkBtn: { 2405 borderWidth: 1, 2406 borderRadius: 24, 2407 paddingHorizontal: 16, 2408 paddingVertical: 12, 2409 marginHorizontal: 10, 2410 marginBottom: 4, 2411 }, 2412}) 2413 2414function ErrorBanner({ 2415 error: standardError, 2416 videoState, 2417 clearError, 2418 clearVideo, 2419}: { 2420 error: string 2421 videoState: VideoState | NoVideoState 2422 clearError: () => void 2423 clearVideo: () => void 2424}) { 2425 const t = useTheme() 2426 const {_} = useLingui() 2427 2428 const enableSquareButtons = useEnableSquareButtons() 2429 2430 const videoError = 2431 videoState.status === 'error' ? videoState.error : undefined 2432 const error = standardError || videoError 2433 2434 const onClearError = () => { 2435 if (standardError) { 2436 clearError() 2437 } else { 2438 clearVideo() 2439 } 2440 } 2441 2442 if (!error) return null 2443 2444 return ( 2445 <Animated.View 2446 style={[a.px_lg, a.pb_sm]} 2447 entering={FadeIn} 2448 exiting={FadeOut}> 2449 <View 2450 style={[ 2451 a.px_md, 2452 a.py_sm, 2453 a.gap_xs, 2454 a.rounded_sm, 2455 t.atoms.bg_contrast_25, 2456 ]}> 2457 <View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}> 2458 <CircleInfoIcon fill={t.palette.negative_400} /> 2459 <Text style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}> 2460 {error} 2461 </Text> 2462 <Button 2463 label={_(msg`Dismiss error`)} 2464 size="tiny" 2465 color="secondary" 2466 variant="ghost" 2467 shape={enableSquareButtons ? 'square' : 'round'} 2468 style={[a.absolute, {top: 0, right: 0}]} 2469 onPress={onClearError}> 2470 <ButtonIcon icon={XIcon} /> 2471 </Button> 2472 </View> 2473 {videoError && videoState.jobId && ( 2474 <Text 2475 style={[ 2476 {paddingLeft: 28}, 2477 a.text_xs, 2478 a.font_semi_bold, 2479 a.leading_snug, 2480 t.atoms.text_contrast_low, 2481 ]}> 2482 <Trans>Job ID: {videoState.jobId}</Trans> 2483 </Text> 2484 )} 2485 </View> 2486 </Animated.View> 2487 ) 2488} 2489 2490function ToolbarWrapper({ 2491 style, 2492 children, 2493}: { 2494 style: StyleProp<ViewStyle> 2495 children: React.ReactNode 2496}) { 2497 if (IS_WEB) return children 2498 return ( 2499 <Animated.View 2500 style={style} 2501 entering={FadeIn.duration(400)} 2502 exiting={FadeOut.duration(400)}> 2503 {children} 2504 </Animated.View> 2505 ) 2506} 2507 2508function VideoUploadToolbar({state}: {state: VideoState}) { 2509 const t = useTheme() 2510 const {_} = useLingui() 2511 const progress = state.progress 2512 const shouldRotate = 2513 state.status === 'processing' && (progress === 0 || progress === 1) 2514 let wheelProgress = shouldRotate ? 0.33 : progress 2515 2516 const rotate = useDerivedValue(() => { 2517 if (shouldRotate) { 2518 return withRepeat( 2519 withTiming(360, { 2520 duration: 2500, 2521 easing: Easing.out(Easing.cubic), 2522 }), 2523 -1, 2524 ) 2525 } 2526 return 0 2527 }) 2528 2529 const animatedStyle = useAnimatedStyle(() => { 2530 return { 2531 transform: [{rotateZ: `${rotate.get()}deg`}], 2532 } 2533 }) 2534 2535 let text = '' 2536 2537 const isGif = state.video?.mimeType === 'image/gif' 2538 2539 switch (state.status) { 2540 case 'compressing': 2541 if (isGif) { 2542 text = _(msg`Compressing GIF...`) 2543 } else { 2544 text = _(msg`Compressing video...`) 2545 } 2546 break 2547 case 'uploading': 2548 if (isGif) { 2549 text = _(msg`Uploading GIF...`) 2550 } else { 2551 text = _(msg`Uploading video...`) 2552 } 2553 break 2554 case 'processing': 2555 if (isGif) { 2556 text = _(msg`Processing GIF...`) 2557 } else { 2558 text = _(msg`Processing video...`) 2559 } 2560 break 2561 case 'error': 2562 text = _(msg`Error`) 2563 wheelProgress = 100 2564 break 2565 case 'done': 2566 if (isGif) { 2567 text = _(msg`GIF uploaded`) 2568 } else { 2569 text = _(msg`Video uploaded`) 2570 } 2571 break 2572 } 2573 2574 return ( 2575 <ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}> 2576 <Animated.View style={[animatedStyle]}> 2577 <ProgressCircle 2578 size={30} 2579 borderWidth={1} 2580 borderColor={t.atoms.border_contrast_low.borderColor} 2581 color={ 2582 state.status === 'error' 2583 ? t.palette.negative_500 2584 : t.palette.primary_500 2585 } 2586 progress={wheelProgress} 2587 /> 2588 </Animated.View> 2589 <Text style={[a.font_semi_bold, a.ml_sm]}>{text}</Text> 2590 </ToolbarWrapper> 2591 ) 2592}