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