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