forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}