Files
bsky-social-app/src/view/com/composer/Composer.tsx
T
2026-07-23 18:25:40 +03:00

2723 lines
79 KiB
TypeScript

import {
Fragment,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import {
ActivityIndicator,
BackHandler,
Keyboard,
type LayoutChangeEvent,
ScrollView,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
type AnimatedRef,
type AnimatedStyle,
Easing,
FadeIn,
FadeOut,
interpolateColor,
LayoutAnimationConfig,
LinearTransition,
runOnUI,
scrollTo,
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withRepeat,
withTiming,
ZoomIn,
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as FileSystem from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyDraftCreateDraft,
AppBskyUnspeccedDefs,
type AppBskyUnspeccedGetPostThreadV2,
type AtpAgent,
AtUri,
ChatBskyGroupDefs,
type RichText,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueries, useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
import {useAppState} from '#/lib/appState'
import {retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until'
import {
MAX_DRAFT_GRAPHEME_LENGTH,
MAX_GRAPHEME_LENGTH,
SUPPORTED_MIME_TYPES,
type SupportedMimeTypes,
VIDEO_MAX_DURATION_MS,
} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
createComposerImage,
pasteImage,
} from '#/state/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
fromPostLanguages,
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton'
import {
ExternalEmbedGif,
ExternalEmbedLink,
} from '#/view/com/composer/ExternalEmbed'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
import {Gallery} from '#/view/com/composer/photos/Gallery'
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
import {TextInput} from '#/view/com/composer/text-input/TextInput'
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as EmojiPicker from '#/components/EmojiPicker'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {
IS_ANDROID,
IS_IOS,
IS_LIQUID_GLASS,
IS_NATIVE,
IS_WEB,
IS_WEB_SAFARI,
} from '#/env'
import {type Gif} from '#/features/gifPicker/types'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {
draftToComposerPosts,
extractLocalRefs,
type RestoredVideo,
} from './drafts/state/api'
import {
loadDraftMedia,
useCleanupPublishedDraftMutation,
useSaveDraftMutation,
} from './drafts/state/queries'
import {type DraftSummary} from './drafts/state/schema'
import {revokeAllMediaUrls} from './drafts/state/storage'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
SelectMediaButton,
type SelectMediaButtonProps,
} from './SelectMediaButton'
import {
type ComposerAction,
composerReducer,
createComposerState,
type EmbedDraft,
MAX_GALLERY_IMAGES,
type PostAction,
type PostDraft,
type ThreadDraft,
} from './state/composer'
import {
NO_VIDEO,
type NoVideoState,
processVideo,
type VideoState,
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {getVideoMetadata} from './videos/metadata'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
type CancelRef = {
onPressCancel: () => void
}
function applyGalleryCap(
currentCount: number,
incoming: ComposerImage[],
):
| {status: 'full'}
| {status: 'partial'; accepted: ComposerImage[]; dropped: number}
| {status: 'ok'; accepted: ComposerImage[]} {
const remaining = MAX_GALLERY_IMAGES - currentCount
if (remaining <= 0) {
return {status: 'full'}
}
if (incoming.length > remaining) {
return {
status: 'partial',
accepted: incoming.slice(0, remaining),
dropped: incoming.length - remaining,
}
}
return {status: 'ok', accepted: incoming}
}
function useAddImagesWithCap(
currentCount: number,
dispatchPostAction: (action: PostAction) => void,
) {
const {t: l} = useLingui()
return useCallback(
(next: ComposerImage[]) => {
const result = applyGalleryCap(currentCount, next)
if (result.status === 'full') {
Toast.show(
l({
message: `You can only add up to ${plural(MAX_GALLERY_IMAGES, {
other: '# images',
})} per post`,
comment:
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
}),
{type: 'warning'},
)
return
}
if (result.status === 'partial') {
Toast.show(
l({
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
comment:
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
}),
{type: 'warning'},
)
}
dispatchPostAction({
type: 'embed_add_images',
images: result.accepted,
})
},
[currentCount, dispatchPostAction, l],
)
}
type Props = ComposerOpts
export const ComposePost = ({
replyTo,
onPost,
onPostSuccess,
quote: initQuote,
mention: initMention,
text: initText,
imageUris: initImageUris,
videoUri: initVideoUri,
openGallery,
logContext,
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef | null>
}) => {
const {currentAccount} = useSession()
const t = useTheme()
const ax = useAnalytics()
const agent = useAgent()
const queryClient = useQueryClient()
const currentDid = currentAccount!.did
const {closeComposer} = useComposerControls()
const {t: l, i18n} = useLingui()
const requireAltTextEnabled = useRequireAltTextEnabled()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const textInputRef = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const emptyPostsPromptControl = Prompt.usePromptControl()
const skipEmptyConfirmedRef = useRef(false)
const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
useSaveDraftMutation()
const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
const {closeAllDialogs} = useDialogStateControlContext()
const {data: preferences} = usePreferencesQuery()
const navigation = useNavigation<NavigationProp>()
const [isPublishing, setIsPublishing] = useState(false)
const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('')
/**
* Track when a draft was created so we can measure draft age in metrics.
* Set when a draft is loaded via handleSelectDraft.
*/
const [loadedDraftCreatedAt, setLoadedDraftCreatedAt] = useState<
string | null
>(null)
/**
* A temporary local reference to a language suggestion that the user has
* accepted. This overrides the global post language preference, but is not
* stored permanently.
*/
const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<
string | null
>(null)
/**
* The language(s) of the post being replied to.
*/
const [replyToLanguages, setReplyToLanguages] = useState<string[]>(
replyTo?.langs || [],
)
/**
* The currently selected languages of the post. Prefer local temporary
* language suggestion over global lang prefs, if available.
*/
const currentLanguages = useMemo(
() =>
acceptedLanguageSuggestion
? [acceptedLanguageSuggestion]
: toPostLanguages(langPrefs.postLanguage),
[acceptedLanguageSuggestion, langPrefs.postLanguage],
)
/**
* When the user selects a language from the composer language selector,
* clear any temporary language suggestions they may have selected
* previously, and any we might try to suggest to them.
*/
const onSelectLanguage = () => {
setAcceptedLanguageSuggestion(null)
setReplyToLanguages([])
}
/**
* Timestamp (ms) of the last honored nudge from language detection.
* Used to rate-limit the pulse animation: we ignore back-to-back
* nudges that arrive within NUDGE_COOLDOWN_MS. Consumers key an effect
* on this value — it only changes when we actually want to re-pulse.
*/
const [languageNudgeAt, setLanguageNudgeAt] = useState(0)
const onLanguageNudge = () => {
const now = Date.now()
// ignore back-to-back nudges within 10s; only update state (and
// therefore re-pulse) once the cooldown has elapsed
setLanguageNudgeAt(prev => (now - prev > 10_000 ? now : prev))
}
const [composerState, composerDispatch] = useReducer(
composerReducer,
{
initImageUris,
initQuoteUri: initQuote?.uri,
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
},
createComposerState,
)
const thread = composerState.thread
// Clear error when composer content changes, but only if all posts are
// back within the character limit.
const allPostsWithinLimit = thread.posts.every(
post => post.richtext.graphemeLength <= MAX_DRAFT_GRAPHEME_LENGTH,
)
const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined =
thread.posts[composerState.activePostIndex + 1]
const dispatch = useCallback(
(postAction: PostAction) => {
composerDispatch({
type: 'update_post',
postId: activePost.id,
postAction,
})
},
[activePost.id],
)
const selectVideo = useCallback(
async (postId: string, asset: ImagePickerAsset) => {
/*
* Share-extension deeplinks deliver a video URI without duration, so
* probe before we decide whether to compress. The picker and web paste
* paths already populate duration upstream. GIFs must skip the probe:
* they have no video track, so the iOS prober never resolves or rejects
* and the await below would hang forever.
*/
if (
asset.duration == null &&
IS_NATIVE &&
asset.mimeType !== 'image/gif'
) {
try {
const probed = await getVideoMetadata(asset.uri)
asset = {
...asset,
mimeType: probed.mimeType ?? asset.mimeType,
width: probed.width ?? asset.width,
height: probed.height ?? asset.height,
duration: probed.duration,
}
} catch (e) {
logger.warn('selectVideo: duration probe failed', {safeMessage: e})
}
}
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
/*
* Fail early on duration so we don't spend time compressing a video the
* server would reject anyway.
*/
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
})
return
}
void processVideo(
asset,
videoAction => {
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_update_video',
videoAction,
},
})
},
agent,
currentDid,
abortController.signal,
i18n,
telemetry,
)
},
[l, i18n, agent, currentDid, composerDispatch, ax.metric],
)
const onInitVideo = useNonReactiveCallback(() => {
if (initVideoUri) {
void selectVideo(activePost.id, initVideoUri)
}
})
useEffect(() => {
onInitVideo()
}, [onInitVideo])
// Fire composer:open metric on mount
useCallOnce(() => {
ax.metric('composer:open', {
logContext: logContext ?? 'Other',
isReply: !!replyTo,
hasQuote: !!initQuote,
hasDraft: false,
})
})()
const clearVideo = useCallback(
(postId: string) => {
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_remove_video',
},
})
},
[composerDispatch],
)
const restoreVideo = useCallback(
async (postId: string, videoInfo: RestoredVideo) => {
try {
logger.debug('restoring video from draft', {
postId,
videoUri: videoInfo.uri,
altText: videoInfo.altText,
captionCount: videoInfo.captions.length,
})
let asset: ImagePickerAsset
if (IS_WEB) {
// Web: Convert blob URL to a File, then get video metadata (returns data URL)
const response = await fetch(videoInfo.uri)
const blob = await response.blob()
const file = new File([blob], 'restored-video', {
type: videoInfo.mimeType,
})
asset = await getVideoMetadata(file)
} else {
let uri = videoInfo.uri
if (IS_ANDROID) {
// Android: expo-file-system double-encodes filenames with special chars.
// The file exists, but react-native-compressor's MediaMetadataRetriever
// can't handle the double-encoded URI. Copy to a temp file with a simple name.
const sourceFile = new FileSystem.File(videoInfo.uri)
const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}`
const tempFile = new FileSystem.File(
FileSystem.Paths.cache,
tempFileName,
)
sourceFile.copy(tempFile)
logger.debug('restoreVideo: copied to temp file', {
source: videoInfo.uri,
temp: tempFile.uri,
})
uri = tempFile.uri
}
asset = await getVideoMetadata(uri)
}
// Start video processing using existing flow
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
if (asset.duration != null && asset.duration > VIDEO_MAX_DURATION_MS) {
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'to_error',
error: l`Videos must be less than 3 minutes long.`,
signal: abortController.signal,
},
},
})
return
}
// Restore alt text immediately
if (videoInfo.altText) {
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText: videoInfo.altText,
signal: abortController.signal,
},
},
})
}
// Restore captions (web only - captions use File objects)
if (IS_WEB && videoInfo.captions.length > 0) {
const captionTracks = videoInfo.captions.map(c => ({
lang: c.lang,
file: new File([c.content], `caption-${c.lang}.vtt`, {
type: 'text/vtt',
}),
}))
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater: () => captionTracks,
signal: abortController.signal,
},
},
})
}
// Start video compression and upload
void processVideo(
asset,
videoAction => {
composerDispatch({
type: 'update_post',
postId,
postAction: {
type: 'embed_update_video',
videoAction,
},
})
},
agent,
currentDid,
abortController.signal,
i18n,
telemetry,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
postId,
error: e,
})
}
},
[l, i18n, agent, currentDid, composerDispatch, ax.metric],
)
const handleSelectDraft = useCallback(
async (draftSummary: DraftSummary) => {
logger.debug('loading draft for editing', {
draftId: draftSummary.id,
})
// Load local media files for the draft
const {loadedMedia} = await loadDraftMedia(draftSummary.draft)
// Extract original localRefs for orphan detection on save
const originalLocalRefs = extractLocalRefs(draftSummary.draft)
logger.debug('draft loaded', {
draftId: draftSummary.id,
loadedMediaCount: loadedMedia.size,
originalLocalRefCount: originalLocalRefs.size,
})
// Convert server draft to composer posts (videos returned separately)
const {posts, restoredVideos} = await draftToComposerPosts(
draftSummary.draft,
loadedMedia,
)
// Dispatch restore action (this also sets draftId in state)
composerDispatch({
type: 'restore_from_draft',
draftId: draftSummary.id,
posts,
threadgateAllow: draftSummary.draft.threadgateAllow,
postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules,
loadedMedia,
originalLocalRefs,
})
// Track when the draft was created for metrics
setLoadedDraftCreatedAt(draftSummary.createdAt)
// Fire draft:load metric
const draftPosts = draftSummary.posts
const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime()
ax.metric('draft:load', {
draftAgeMs,
hasText: draftPosts.some(p => p.text.trim().length > 0),
hasImages: draftPosts.some(p => p.images && p.images.length > 0),
hasVideo: draftPosts.some(p => !!p.video),
hasGif: draftPosts.some(p => !!p.gif),
postCount: draftPosts.length,
})
// Initiate video processing for any restored videos
// This is async but we don't await - videos process in the background
for (const [postIndex, videoInfo] of restoredVideos) {
const postId = posts[postIndex].id
void restoreVideo(postId, videoInfo)
}
},
[composerDispatch, restoreVideo, ax],
)
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
closeComposer()
clearThumbnailCache(queryClient)
revokeAllMediaUrls()
}, [closeComposer, queryClient])
const getDraftSaveError = useCallback(
(e: unknown): string => {
if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
return l`You've reached the maximum number of drafts`
}
return l`Failed to save draft`
},
[l],
)
const validateDraftTextOrError = useCallback((): boolean => {
const tooLong = composerState.thread.posts.some(
post => post.richtext.graphemeLength > MAX_DRAFT_GRAPHEME_LENGTH,
)
if (tooLong) {
setError(
l`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.'})}`,
)
return false
}
return true
}, [composerState.thread.posts, l])
const handleSaveDraft = useCallback(async () => {
setError('')
if (!validateDraftTextOrError()) {
return
}
const isNewDraft = !composerState.draftId
try {
const result = await saveDraft({
composerState,
existingDraftId: composerState.draftId,
})
composerDispatch({type: 'mark_saved', draftId: result.draftId})
// Fire draft:save metric
const posts = composerState.thread.posts
ax.metric('draft:save', {
isNewDraft,
hasText: posts.some(p => p.richtext.text.trim().length > 0),
hasImages: posts.some(
p =>
p.embed.media?.type === 'images' ||
p.embed.media?.type === 'gallery',
),
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
hasQuote: posts.some(p => !!p.embed.quote),
hasLink: posts.some(p => !!p.embed.link),
postCount: posts.length,
textLength: posts[0].richtext.text.length,
})
onClose()
} catch (e) {
logger.error('Failed to save draft', {error: e})
setError(getDraftSaveError(e))
}
}, [
saveDraft,
composerState,
composerDispatch,
onClose,
ax,
validateDraftTextOrError,
getDraftSaveError,
])
// Save without closing - for use by DraftsButton
const saveCurrentDraft = useCallback(async (): Promise<{
success: boolean
}> => {
setError('')
if (!validateDraftTextOrError()) {
return {success: false}
}
try {
const result = await saveDraft({
composerState,
existingDraftId: composerState.draftId,
})
composerDispatch({type: 'mark_saved', draftId: result.draftId})
return {success: true}
} catch (e) {
setError(getDraftSaveError(e))
return {success: false}
}
}, [
saveDraft,
composerState,
composerDispatch,
validateDraftTextOrError,
getDraftSaveError,
])
// Handle discard action - fires metric and closes composer
const handleDiscard = useCallback(() => {
const posts = thread.posts
const hasContent = posts.some(
post =>
post.richtext.text.trim().length > 0 ||
post.embed.media ||
post.embed.link,
)
ax.metric('draft:discard', {
logContext: 'ComposerClose',
hadContent: hasContent,
textLength: posts[0].richtext.text.length,
})
onClose()
}, [thread.posts, ax, onClose])
// Check if composer is empty (no content to save)
const isComposerEmpty = useMemo(() => {
// Has multiple posts means it's not empty
if (thread.posts.length > 1) return false
const firstPost = thread.posts[0]
// Has text
if (firstPost.richtext.text.trim().length > 0) return false
// Has media
if (firstPost.embed.media) return false
// Has quote
if (firstPost.embed.quote) return false
// Has link
if (firstPost.embed.link) return false
return true
}, [thread.posts])
// Clear the composer (discard current content)
const handleClearComposer = useCallback(() => {
composerDispatch({
type: 'clear',
initInteractionSettings: preferences?.postInteractionSettings,
})
}, [composerDispatch, preferences?.postInteractionSettings])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
() => ({
paddingTop: IS_ANDROID ? insets.top : 0,
paddingBottom: insets.bottom,
}),
[insets.top, insets.bottom],
)
const onPressCancel = useCallback(() => {
if (textInputRef.current?.maybeClosePopup()) {
return
}
const hasContent = thread.posts.some(
post =>
post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link,
)
// Show discard prompt if there's content AND either:
// - No draft is loaded (new composition)
// - Draft is loaded but has been modified
if (hasContent && (!composerState.draftId || composerState.isDirty)) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
} else {
onClose()
}
}, [
thread,
composerState.draftId,
composerState.isDirty,
closeAllDialogs,
discardPromptControl,
onClose,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
// On Android, pressing Back should ask confirmation.
useEffect(() => {
if (!IS_ANDROID) {
return
}
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
if (closeAllDialogs()) {
return true
}
onPressCancel()
return true
},
)
return () => {
backHandler.remove()
}
}, [onPressCancel, closeAllDialogs])
const missingAltError = useMemo(() => {
if (!requireAltTextEnabled) {
return
}
for (let i = 0; i < thread.posts.length; i++) {
const media = thread.posts[i].embed.media
if (media) {
if (
(media.type === 'images' || media.type === 'gallery') &&
media.images.some(img => !img.alt)
) {
return l`One or more images is missing alt text.`
}
if (media.type === 'gif' && !media.alt) {
return l`One or more GIFs is missing alt text.`
}
if (
media.type === 'video' &&
media.video.status !== 'error' &&
!media.video.altText
) {
return l`One or more videos is missing alt text.`
}
}
}
}, [thread, requireAltTextEnabled, l])
// Subscribe to the resolve-link cache for any link URIs in the thread so we
// can detect chat invites that resolved to no preview (revoked/expired) and
// block publishing - otherwise the post would go out without the embed.
const linkUris = thread.posts
.filter(post => post.embed.link)
.map(post => post.embed.link!.uri)
const linkQueries = useQueries({
queries: linkUris.map(uri => ({
...resolveLinkQueryOptions(agent, uri),
enabled: false,
})),
})
const hasUnavailableChatInvite = linkQueries.some(
q =>
q.data?.type === 'chat-invite' &&
!ChatBskyGroupDefs.isJoinLinkPreviewView(q.data.view),
)
const canPost =
!missingAltError &&
!hasUnavailableChatInvite &&
thread.posts.some(post => !isEmptyPost(post)) &&
thread.posts.every(
post =>
isEmptyPost(post) ||
(post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
!(
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'error'
)),
)
const getFilteredThread = useCallback((): {
type: 'none' | 'trailing-only' | 'non-trailing'
filteredThread: ThreadDraft
} => {
const nonEmptyPosts = thread.posts.filter(post => !isEmptyPost(post))
if (nonEmptyPosts.length === thread.posts.length) {
return {type: 'none', filteredThread: thread}
}
let lastNonEmptyIndex = -1
for (let i = thread.posts.length - 1; i >= 0; i--) {
if (!isEmptyPost(thread.posts[i])) {
lastNonEmptyIndex = i
break
}
}
const hasNonTrailingEmpty = thread.posts.some(
(post, i) => i < lastNonEmptyIndex && isEmptyPost(post),
)
const filteredThread: ThreadDraft = {...thread, posts: nonEmptyPosts}
return {
type: hasNonTrailingEmpty ? 'non-trailing' : 'trailing-only',
filteredThread,
}
}, [thread])
const onPressPublish = useCallback(async () => {
if (isPublishing) {
return
}
if (!canPost) {
return
}
const {type: emptyType, filteredThread} = getFilteredThread()
if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) {
emptyPostsPromptControl.open()
return
}
if (
filteredThread.posts.some(
post =>
post.embed.media?.type === 'video' &&
post.embed.media.video.asset &&
post.embed.media.video.status !== 'done',
)
) {
setPublishOnUpload(true)
return
}
skipEmptyConfirmedRef.current = false
setError('')
setIsPublishing(true)
let postUri: string | undefined
let postSuccessData: OnPostSuccessData
try {
logger.info(`composer: posting...`)
postUri = (
await apilib.post(agent, queryClient, {
thread: filteredThread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: currentLanguages,
})
).uris[0]
// Fire published event for every video that made it into the post.
// The status guard upstream ensures each video.telemetry is present and
// processing has completed by this point.
for (const post of filteredThread.posts) {
if (post.embed.media?.type === 'video') {
post.embed.media.video.telemetry?.published()
}
}
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
*/
try {
if (postUri) {
logger.info(`composer: waiting for app view`)
const posts = await retry(
5,
_e => true,
async () => {
const res = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: postUri!,
above: false,
below: filteredThread.posts.length - 1,
branchingFactor: 1,
})
if (res.data.thread.length !== filteredThread.posts.length) {
throw new Error(`composer: app view is not ready`)
}
if (
!res.data.thread.every(p =>
AppBskyUnspeccedDefs.isThreadItemPost(p.value),
)
) {
throw new Error(`composer: app view returned non-post items`)
}
return res.data.thread
},
1e3,
)
postSuccessData = {
replyToUri: replyTo?.uri,
posts,
}
}
} catch (waitErr) {
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
}
} catch (e) {
logger.error(e instanceof Error ? e : String(e), {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p =>
p.embed.media?.type === 'images' ||
p.embed.media?.type === 'gallery',
),
})
let err = e instanceof Error ? cleanError(e.message) : String(e)
if (
e instanceof apilib.ReplyDeletedError ||
err.includes('not locate record')
) {
err = l`We're sorry! The post you are replying to has been deleted.`
} else if (e instanceof EmbeddingDisabledError) {
err = l`This post's author has disabled quote posts.`
}
setError(err)
setIsPublishing(false)
return
} finally {
if (postUri) {
let index = 0
for (let post of filteredThread.posts) {
ax.metric('post:create', {
imageCount:
post.embed.media?.type === 'images' ||
post.embed.media?.type === 'gallery'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: filteredThread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: fromPostLanguages(currentLanguages),
logContext: 'Composer',
})
index++
}
}
if (filteredThread.posts.length > 1) {
ax.metric('thread:create', {
postCount: filteredThread.posts.length,
isReply: !!replyTo,
})
}
if (postUri) {
for (const q of linkQueries) {
const resolved = q.data
if (
resolved?.type === 'chat-invite' &&
ChatBskyGroupDefs.isJoinLinkPreviewView(resolved.view)
) {
ax.metric('groupchat:inviteLink:shared', {
convoId: resolved.view.convoId,
method: 'post',
})
}
}
}
}
if (postUri && !replyTo) {
emitPostCreated()
}
// Clean up draft and its media after successful publish
if (composerState.draftId && composerState.originalLocalRefs) {
// Fire draft:post metric
if (loadedDraftCreatedAt) {
const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime()
ax.metric('draft:post', {
draftAgeMs,
wasEdited: composerState.isDirty,
})
}
logger.debug('post published, cleaning up draft', {
draftId: composerState.draftId,
mediaFileCount: composerState.originalLocalRefs.size,
})
cleanupPublishedDraft({
draftId: composerState.draftId,
originalLocalRefs: composerState.originalLocalRefs,
})
}
setLangPrefs.savePostLanguageToHistory()
if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
void whenAppViewReady(agent, initQuote.uri, res => {
const anchor = res.data.thread.at(0)
if (
AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
anchor.value.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
return true
}
return false
})
} else {
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
}
onClose()
setTimeout(() => {
Toast.show(
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
{filteredThread.posts.length > 1
? l`Your posts were sent`
: replyTo
? l`Your reply was sent`
: l`Your post was sent`}
</Toast.Text>
{postUri && (
<Toast.Action
label={l`View post`}
onPress={() => {
const {host: name, rkey} = new AtUri(postUri)
navigation.navigate('PostThread', {name, rkey})
}}>
<Trans context="Action to view the post the user just created">
View
</Trans>
</Toast.Action>
)}
</Toast.Outer>,
{type: 'success'},
)
}, 500)
}, [
l,
ax,
agent,
canPost,
isPublishing,
currentLanguages,
onClose,
onPost,
onPostSuccess,
initQuote,
replyTo,
setLangPrefs,
queryClient,
navigation,
composerState.draftId,
composerState.originalLocalRefs,
composerState.isDirty,
cleanupPublishedDraft,
loadedDraftCreatedAt,
emptyPostsPromptControl,
getFilteredThread,
linkQueries,
])
const handleConfirmSkipEmpty = () => {
skipEmptyConfirmedRef.current = true
void onPressPublish()
}
// Preserves the referential identity passed to each post item.
// Avoids re-rendering all posts on each keystroke.
const onComposerPostPublish = useNonReactiveCallback(() => {
void onPressPublish()
})
useEffect(() => {
if (publishOnUpload) {
let erroredVideos = 0
let uploadingVideos = 0
for (let post of thread.posts) {
if (isEmptyPost(post)) continue
if (post.embed.media?.type === 'video') {
const video = post.embed.media.video
if (video.status === 'error') {
erroredVideos++
} else if (video.status !== 'done') {
uploadingVideos++
}
}
}
if (erroredVideos > 0) {
setPublishOnUpload(false)
} else if (uploadingVideos === 0) {
setPublishOnUpload(false)
void onPressPublish()
}
}
}, [thread.posts, onPressPublish, publishOnUpload])
// TODO: It might make more sense to display this error per-post.
// Right now we're just displaying the first one.
let erroredVideoPostId: string | undefined
let erroredVideo: VideoState | NoVideoState = NO_VIDEO
for (let i = 0; i < thread.posts.length; i++) {
const post = thread.posts[i]
if (
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'error'
) {
erroredVideoPostId = post.id
erroredVideo = post.embed.media.video
break
}
}
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
useEffect(() => {
if (composerState.mutableNeedsFocusActive) {
composerState.mutableNeedsFocusActive = false
// On Android, this risks getting the cursor stuck behind the keyboard.
// Not worth it.
if (!IS_ANDROID) {
textInputRef.current?.focus()
}
}
}, [composerState])
useEffect(() => {
// Safari ignores `overscroll-behavior`, so horizontal trackpad swipes over
// the composer (e.g. on a quote post) can still trigger the browser's
// back/forward navigation gesture. Suppress predominantly-horizontal wheel
// events so the history-nav gesture never fires. Chrome and Firefox are
// covered by the `overscrollBehaviorX: 'contain'` style on the ScrollView.
if (!IS_WEB_SAFARI) return
const el =
scrollViewRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
e.preventDefault()
}
el.addEventListener('wheel', onWheel, {passive: false})
return () => el.removeEventListener('wheel', onWheel)
}, [scrollViewRef])
const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined
const {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
} = useScrollTracker({
scrollViewRef,
stickyBottom: isLastThreadedPost,
})
const keyboardVerticalOffset = useKeyboardVerticalOffset()
const footer = (
<>
<SuggestedLanguage
text={activePost.richtext.text}
replyToLanguages={replyToLanguages}
currentLanguages={currentLanguages}
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
onNudge={onLanguageNudge}
/>
<ComposerPills
isReply={!!replyTo}
post={activePost}
thread={composerState.thread}
dispatch={composerDispatch}
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
/>
<ComposerFooter
post={activePost}
dispatch={dispatch}
showAddButton={
!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
}
onError={setError}
onSelectVideo={selectVideo}
onAddPost={() => {
composerDispatch({
type: 'add_post',
})
}}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
languageNudgeAt={languageNudgeAt}
openGallery={openGallery}
textInputRef={textInputRef}
/>
</>
)
const IS_WEBFooterSticky = !IS_NATIVE && thread.posts.length > 1
return (
<BottomSheetPortalProvider>
<KeyboardAvoidingView
testID="composePostView"
behavior={IS_IOS ? 'padding' : 'height'}
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
<View
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={() => void onPressPublish()}
onSelectDraft={draft => void handleSelectDraft(draft)}
onSaveDraft={saveCurrentDraft}
onDiscard={handleClearComposer}
isEmpty={isComposerEmpty}
isDirty={composerState.isDirty}
isEditingDraft={!!composerState.draftId}
canSaveDraft={allPostsWithinLimit}
textLength={thread.posts[0].richtext.text.length}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
/>
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={[
a.flex_1,
web({
scrollbarGutter: 'stable',
scrollbarColor: `${t.palette.contrast_200} transparent`,
// Prevent horizontal trackpad swipes from triggering the
// browser's back/forward overscroll-navigation gesture.
// Handles Chrome and Firefox; Safari is handled separately
// via a wheel listener since it ignores overscroll-behavior.
overscrollBehaviorX: 'contain',
}),
]}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<Fragment key={post.id + (composerState.draftId ?? '')}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInputRef={post.id === activePost.id ? textInputRef : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
canRemoveQuote={index > 0 || !initQuote}
onSelectVideo={selectVideo}
onClearVideo={clearVideo}
onPublish={onComposerPostPublish}
onError={setError}
/>
{IS_WEBFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</Fragment>
))}
</Animated.ScrollView>
{!IS_WEBFooterSticky && footer}
</View>
{replyTo ? (
<Prompt.Basic
control={discardPromptControl}
title={l`Discard draft?`}
description=""
confirmButtonCta={l`Discard`}
confirmButtonColor="negative"
onConfirm={handleDiscard}
/>
) : (
<Prompt.Outer control={discardPromptControl}>
<Prompt.Content>
<Prompt.TitleText>
{allPostsWithinLimit ? (
composerState.draftId ? (
<Trans>Save changes?</Trans>
) : (
<Trans>Save draft?</Trans>
)
) : (
<Trans>Discard post?</Trans>
)}
</Prompt.TitleText>
<Prompt.DescriptionText>
{allPostsWithinLimit ? (
composerState.draftId ? (
<Trans>
You have unsaved changes to this draft, would you like to
save them?
</Trans>
) : (
<Trans>
Would you like to save this as a draft to edit later?
</Trans>
)
) : (
<Trans>You can only save drafts up to 1000 characters.</Trans>
)}
</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
{allPostsWithinLimit && (
<Prompt.Action
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
onPress={() => void handleSaveDraft()}
color="primary"
/>
)}
<Prompt.Action
cta={l`Discard`}
onPress={handleDiscard}
color="negative_subtle"
/>
<Prompt.Cancel cta={l`Keep editing`} />
</Prompt.Actions>
</Prompt.Outer>
)}
<Prompt.Basic
control={emptyPostsPromptControl}
title={l`Skip empty posts?`}
description={l`Your thread has empty posts that will be skipped. The remaining posts will be published as a thread.`}
confirmButtonCta={l`Post anyway`}
cancelButtonCta={l`Keep editing`}
onConfirm={handleConfirmSkipEmpty}
/>
</KeyboardAvoidingView>
</BottomSheetPortalProvider>
)
}
let ComposerPost = memo(function ComposerPost({
post,
dispatch,
textInputRef,
isActive,
isReply,
isFirstPost,
isLastPost,
isPartOfThread,
canRemovePost,
canRemoveQuote,
onClearVideo,
onSelectVideo,
onError,
onPublish,
}: {
post: PostDraft
dispatch: (action: ComposerAction) => void
textInputRef: React.RefObject<TextInputRef | null> | null
isActive: boolean
isReply: boolean
isFirstPost: boolean
isLastPost: boolean
isPartOfThread: boolean
canRemovePost: boolean
canRemoveQuote: boolean
onClearVideo: (postId: string) => void
onSelectVideo: (
postId: string,
asset: ImagePickerAsset,
) => void | Promise<void>
onError: (error: string) => void
onPublish: (richtext: RichText) => void
}) {
const {currentAccount} = useSession()
const currentDid = currentAccount!.did
const {t: l} = useLingui()
const {data: currentProfile} = useProfileQuery({did: currentDid})
const richtext = post.richtext
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
const forceMinHeight = IS_WEB && isTextOnly && isActive
const selectTextInputPlaceholder = isReply
? isFirstPost
? l`Write your reply`
: l`Add another post`
: l`What's up?`
const discardPromptControl = Prompt.usePromptControl()
const dispatchPost = useCallback(
(action: PostAction) => {
dispatch({
type: 'update_post',
postId: post.id,
postAction: action,
})
},
[dispatch, post.id],
)
const postImagesCount =
post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
? post.embed.media.images.length
: 0
const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost)
const onNewLink = useCallback(
(uri: string) => {
dispatchPost({type: 'embed_add_uri', uri})
},
[dispatchPost],
)
const onPhotoPasted = useCallback(
async (uri: string) => {
if (
uri.startsWith('data:video/') ||
(IS_WEB && uri.startsWith('data:image/gif'))
) {
if (IS_NATIVE) return // web only
const [mimeType] = uri.slice('data:'.length).split(';')
if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
Toast.show(l`Unsupported video type: ${mimeType}`, {
type: 'error',
})
return
}
const name = `pasted.${mimeToExt(mimeType)}`
const file = await fetch(uri)
.then(res => res.blob())
.then(blob => new File([blob], name, {type: mimeType}))
void onSelectVideo(post.id, await getVideoMetadata(file))
} else {
const res = await pasteImage(uri)
onImageAdd([res])
}
},
[post.id, onSelectVideo, onImageAdd, l],
)
useHideKeyboardOnBackground()
return (
<View
style={[
a.mx_lg,
a.mb_sm,
!isActive && isLastPost && a.mb_lg,
!isActive && styles.inactivePost,
isTextOnly && isLastPost && IS_NATIVE && a.flex_grow,
]}>
<View style={[a.flex_row, IS_NATIVE && a.flex_1]}>
<UserAvatar
avatar={currentProfile?.avatar}
size={42}
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
style={[a.mt_xs]}
/>
<TextInput
ref={textInputRef}
style={[a.pt_xs]}
richtext={richtext}
placeholder={selectTextInputPlaceholder}
autoFocus={isLastPost}
webForceMinHeight={forceMinHeight}
// To avoid overlap with the close button:
hasRightPadding={isPartOfThread}
isActive={isActive}
setRichText={rt => {
dispatchPost({type: 'update_richtext', richtext: rt})
}}
onFocus={() => {
dispatch({
type: 'focus_post',
postId: post.id,
})
}}
onPhotoPasted={uri => void onPhotoPasted(uri)}
onNewLink={onNewLink}
onError={onError}
onPressPublish={onPublish}
accessible={true}
accessibilityLabel={l`Write post`}
accessibilityHint={l`Compose posts up to ${plural(
MAX_GRAPHEME_LENGTH || 0,
{
other: '# characters',
},
)} in length`}
/>
</View>
{canRemovePost && isActive && (
<>
<Button
label={l`Delete post`}
size="small"
color="secondary"
variant="ghost"
shape="round"
style={[a.absolute, {top: 0, right: 0}]}
onPress={() => {
if (
post.shortenedGraphemeLength > 0 ||
post.embed.media ||
post.embed.link ||
post.embed.quote
) {
discardPromptControl.open()
} else {
dispatch({
type: 'remove_post',
postId: post.id,
})
}
}}>
<ButtonIcon icon={XIcon} />
</Button>
<Prompt.Basic
control={discardPromptControl}
title={l`Discard post?`}
description={l`Are you sure you'd like to discard this post?`}
onConfirm={() => {
dispatch({
type: 'remove_post',
postId: post.id,
})
}}
confirmButtonCta={l`Discard`}
confirmButtonColor="negative"
/>
</>
)}
<ComposerEmbeds
canRemoveQuote={canRemoveQuote}
embed={post.embed}
dispatch={dispatchPost}
clearVideo={() => onClearVideo(post.id)}
isActivePost={isActive}
/>
</View>
)
})
function ComposerTopBar({
canPost,
isReply,
isPublishQueued,
isPublishing,
isThread,
publishingStage,
onCancel,
onPublish,
onSelectDraft,
onSaveDraft,
onDiscard,
isEmpty,
isDirty,
isEditingDraft,
canSaveDraft,
textLength,
topBarAnimatedStyle,
children,
}: {
isPublishing: boolean
publishingStage: string
canPost: boolean
isReply: boolean
isPublishQueued: boolean
isThread: boolean
onCancel: () => void
onPublish: () => void
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<{success: boolean}>
onDiscard: () => void
isEmpty: boolean
isDirty: boolean
isEditingDraft: boolean
canSaveDraft: boolean
textLength: number
topBarAnimatedStyle: AnimatedStyle<ViewStyle>
children?: React.ReactNode
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<Animated.View
style={topBarAnimatedStyle}
layout={native(LinearTransition)}>
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
IS_LIQUID_GLASS ? [a.px_lg, a.pt_lg, a.pb_md] : [a.p_sm],
]}>
<Button
label={l`Cancel`}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[{paddingLeft: 7, paddingRight: 7}]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
onPress={onCancel}
accessibilityHint={l`Closes post composer and discards post draft`}>
<ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isPublishing ? (
<>
<Text style={[t.atoms.text_contrast_medium]}>
{publishingStage}
</Text>
<View style={styles.postBtn}>
<ActivityIndicator />
</View>
</>
) : (
<>
{!isReply && (
<DraftsButton
onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft}
onDiscard={onDiscard}
isEmpty={isEmpty}
isDirty={isDirty}
isEditingDraft={isEditingDraft}
canSaveDraft={canSaveDraft}
textLength={textLength}
/>
)}
<Button
testID="composerPublishBtn"
label={
isReply
? isThread
? l({
message: 'Publish replies',
comment:
'Accessibility label for button to publish multiple replies in a thread',
})
: l({
message: 'Publish reply',
comment:
'Accessibility label for button to publish a single reply',
})
: isThread
? l({
message: 'Publish posts',
comment:
'Accessibility label for button to publish multiple posts in a thread',
})
: l({
message: 'Publish post',
comment:
'Accessibility label for button to publish a single post',
})
}
color="primary"
size="small"
onPress={onPublish}
disabled={!canPost || isPublishQueued}>
<ButtonText style={[a.text_md]} maxFontSizeMultiplier={2}>
{isReply ? (
<Trans context="action">Reply</Trans>
) : isThread ? (
<Trans context="action">Post All</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</ButtonText>
</Button>
</>
)}
</View>
{children}
</Animated.View>
)
}
function AltTextReminder({error}: {error: string}) {
return (
<Admonition type="error" style={[a.mt_2xs, a.mb_sm, a.mx_lg]}>
{error}
</Admonition>
)
}
function ComposerEmbeds({
embed,
dispatch,
clearVideo,
canRemoveQuote,
isActivePost,
}: {
embed: EmbedDraft
dispatch: (action: PostAction) => void
clearVideo: () => void
canRemoveQuote: boolean
isActivePost: boolean
}) {
const video = embed.media?.type === 'video' ? embed.media.video : null
return (
<>
{(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
<Gallery images={embed.media.images} dispatch={dispatch} />
)}
{embed.media?.type === 'gif' && (
<View style={[a.relative, a.mt_lg]} key={embed.media.gif.url}>
<ExternalEmbedGif
gif={embed.media.gif}
onRemove={() => dispatch({type: 'embed_remove_gif'})}
/>
<GifAltTextDialog
gif={embed.media.gif}
altText={embed.media.alt ?? ''}
onSubmit={(altText: string) => {
dispatch({type: 'embed_update_gif', alt: altText})
}}
/>
</View>
)}
{!embed.media && embed.link && (
<View style={[a.relative, a.mt_lg]} key={embed.link.uri}>
<ExternalEmbedLink
uri={embed.link.uri}
hasQuote={!!embed.quote}
onRemove={() => dispatch({type: 'embed_remove_link'})}
/>
</View>
)}
<LayoutAnimationConfig skipExiting>
{video && (
<Animated.View
style={[a.w_full, a.mt_lg]}
entering={native(ZoomIn)}
exiting={native(ZoomOut)}>
{video.asset &&
(video.status === 'compressing' ? (
<VideoTranscodeProgress
asset={video.asset}
progress={video.progress}
clear={clearVideo}
/>
) : video.video ? (
<VideoPreview
asset={video.asset}
video={video.video}
isActivePost={isActivePost}
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={video.altText}
saveAltText={altText =>
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText,
signal: video.abortController.signal,
},
})
}
captions={video.captions}
setCaptions={updater => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater,
signal: video.abortController.signal,
},
})
}}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
{embed.quote?.uri ? (
<View
style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], IS_WEB && [a.pb_md]]}>
<View style={[a.relative]}>
<LazyQuoteEmbed uri={embed.quote.uri} linkDisabled />
{canRemoveQuote && (
<ExternalEmbedRemoveBtn
onRemove={() => dispatch({type: 'embed_remove_quote'})}
style={{top: 16}}
/>
)}
</View>
</View>
) : null}
</>
)
}
function ComposerPills({
isReply,
thread,
post,
dispatch,
bottomBarAnimatedStyle,
}: {
isReply: boolean
thread: ThreadDraft
post: PostDraft
dispatch: (action: ComposerAction) => void
bottomBarAnimatedStyle: AnimatedStyle<ViewStyle>
}) {
const t = useTheme()
const media = post.embed.media
const hasMedia =
media?.type === 'images' ||
media?.type === 'gallery' ||
media?.type === 'gif' ||
media?.type === 'video'
const hasLink = !!post.embed.link
// Don't render anything if no pills are going to be displayed
if (isReply && !hasMedia && !hasLink) {
return null
}
return (
<Animated.View
style={[a.flex_row, a.p_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
<ScrollView
contentContainerStyle={[a.gap_sm]}
horizontal={true}
bounces={false}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}>
{isReply ? null : (
<ThreadgateBtn
postgate={thread.postgate}
onChangePostgate={nextPostgate => {
dispatch({type: 'update_postgate', postgate: nextPostgate})
}}
threadgateAllowUISettings={thread.threadgate}
onChangeThreadgateAllowUISettings={nextThreadgate => {
dispatch({
type: 'update_threadgate',
threadgate: nextThreadgate,
})
}}
style={bottomBarAnimatedStyle}
/>
)}
{hasMedia || hasLink ? (
<LabelsBtn
labels={post.labels}
onChange={nextLabels => {
dispatch({
type: 'update_post',
postId: post.id,
postAction: {
type: 'update_labels',
labels: nextLabels,
},
})
}}
/>
) : null}
</ScrollView>
</Animated.View>
)
}
function ComposerFooter({
post,
dispatch,
showAddButton,
onSelectVideo,
onAddPost,
currentLanguages,
onSelectLanguage,
languageNudgeAt,
openGallery,
textInputRef,
}: {
post: PostDraft
dispatch: (action: PostAction) => void
showAddButton: boolean
onError: (error: string) => void
onSelectVideo: (
postId: string,
asset: ImagePickerAsset,
) => void | Promise<void>
onAddPost: () => void
currentLanguages: string[]
onSelectLanguage?: (language: string) => void
languageNudgeAt: number
openGallery?: boolean
textInputRef: React.RefObject<TextInputRef | null>
}) {
const t = useTheme()
const {t: l} = useLingui()
const {gtPhone} = useBreakpoints()
/*
* Once we've allowed a certain type of asset to be selected, we don't allow
* other types of media to be selected.
*/
const [selectedAssetsType, setSelectedAssetsType] = useState<
AssetType | undefined
>(undefined)
const media = post.embed.media
const images =
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
const video = media?.type === 'video' ? media.video : null
const isMaxImages = images.length >= MAX_GALLERY_IMAGES
const isMaxVideos = !!video
let selectedAssetsCount = 0
let isMediaSelectionDisabled = false
if (media?.type === 'images' || media?.type === 'gallery') {
isMediaSelectionDisabled = isMaxImages
selectedAssetsCount = images.length
} else if (media?.type === 'video') {
isMediaSelectionDisabled = isMaxVideos
selectedAssetsCount = 1
} else {
isMediaSelectionDisabled = !!media
}
const onImageAdd = useAddImagesWithCap(images.length, dispatch)
const onSelectGif = useCallback(
(gif: Gif) => {
dispatch({type: 'embed_add_gif', gif})
},
[dispatch],
)
/*
* Reset if the user clears any selected media
*/
if (selectedAssetsType !== undefined && !media) {
setSelectedAssetsType(undefined)
}
const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>(
async ({type, assets, errors}) => {
setSelectedAssetsType(type)
if (assets.length) {
if (type === 'image') {
const selectedImages: ComposerImage[] = []
await Promise.all(
assets.map(async image => {
const composerImage = await createComposerImage({
path: image.uri,
width: image.width,
height: image.height,
mime: image.mimeType!,
})
selectedImages.push(composerImage)
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
safeMessage: e instanceof Error ? e.message : String(e),
})
})
onImageAdd(selectedImages)
} else if (type === 'video') {
void onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
void onSelectVideo(post.id, assets[0])
}
}
errors.map(error => {
Toast.show(error, {
type: 'warning',
})
})
},
[post.id, onSelectVideo, onImageAdd],
)
return (
<View
style={[
a.flex_row,
a.py_xs,
{paddingLeft: 7, paddingRight: 16},
a.align_center,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_medium,
a.justify_between,
]}>
<View style={[a.flex_row, a.align_center]}>
<LayoutAnimationConfig skipEntering skipExiting>
{video && video.status !== 'done' ? (
<VideoUploadToolbar state={video} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectMediaButton
disabled={isMediaSelectionDisabled}
allowedAssetTypes={selectedAssetsType}
selectedAssetsCount={selectedAssetsCount}
onSelectAssets={onSelectAssets}
autoOpen={openGallery}
/>
<OpenCameraBtn
disabled={
media?.type === 'images' || media?.type === 'gallery'
? isMaxImages
: !!media
}
onAdd={onImageAdd}
/>
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
{IS_WEB && gtPhone ? (
<EmojiPicker.Root nextFocusRef={textInputRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props}) => (
<Button
style={a.p_sm}
label={props.accessibilityLabel}
variant="ghost"
shape="round"
color="primary"
{...props}>
<EmojiSmileIcon size="lg" />
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
) : null}
</ToolbarWrapper>
)}
</LayoutAnimationConfig>
</View>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
{showAddButton && (
<Button
label={l`Add another post to thread`}
onPress={onAddPost}
style={[a.p_sm]}
variant="ghost"
shape="round"
color="primary">
<PlusIcon size="lg" />
</Button>
)}
<PostLanguageSelect
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
nudgeAt={languageNudgeAt}
/>
<CharProgress
count={post.shortenedGraphemeLength}
style={{width: 65}}
/>
</View>
</View>
)
}
export function useComposerCancelRef() {
return useRef<CancelRef>(null)
}
function useScrollTracker({
scrollViewRef,
stickyBottom,
}: {
scrollViewRef: AnimatedRef<Animated.ScrollView>
stickyBottom: boolean
}) {
const t = useTheme()
const contentOffset = useSharedValue(0)
const scrollViewHeight = useSharedValue(Infinity)
const contentHeight = useSharedValue(0)
const hasScrolledToTop = useDerivedValue(() =>
withTiming(contentOffset.get() === 0 ? 1 : 0),
)
const hasScrolledToBottom = useDerivedValue(() =>
withTiming(
contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get()
? 1
: 0,
),
)
const showHideBottomBorder = useCallback(
({
newContentHeight,
newContentOffset,
newScrollViewHeight,
}: {
newContentHeight?: number
newContentOffset?: number
newScrollViewHeight?: number
}) => {
'worklet'
if (typeof newContentHeight === 'number')
contentHeight.set(Math.floor(newContentHeight))
if (typeof newContentOffset === 'number')
contentOffset.set(Math.floor(newContentOffset))
if (typeof newScrollViewHeight === 'number')
scrollViewHeight.set(Math.floor(newScrollViewHeight))
},
[contentHeight, contentOffset, scrollViewHeight],
)
const scrollHandler = useAnimatedScrollHandler({
onScroll: event => {
'worklet'
showHideBottomBorder({
newContentOffset: event.contentOffset.y,
newContentHeight: event.contentSize.height,
newScrollViewHeight: event.layoutMeasurement.height,
})
},
})
const onScrollViewContentSizeChangeUIThread = useCallback(
(newContentHeight: number) => {
'worklet'
const oldContentHeight = contentHeight.get()
let shouldScrollToBottom = false
if (stickyBottom && newContentHeight > oldContentHeight) {
const isFairlyCloseToBottom =
oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get()
if (isFairlyCloseToBottom) {
shouldScrollToBottom = true
}
}
showHideBottomBorder({newContentHeight})
if (shouldScrollToBottom) {
scrollTo(scrollViewRef, 0, newContentHeight, true)
}
},
[
showHideBottomBorder,
scrollViewRef,
contentHeight,
stickyBottom,
contentOffset,
scrollViewHeight,
],
)
const onScrollViewContentSizeChange = useCallback(
(_width: number, height: number) => {
runOnUI(onScrollViewContentSizeChangeUIThread)(height)
},
[onScrollViewContentSizeChangeUIThread],
)
const onScrollViewLayout = useCallback(
(evt: LayoutChangeEvent) => {
showHideBottomBorder({
newScrollViewHeight: evt.nativeEvent.layout.height,
})
},
[showHideBottomBorder],
)
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: interpolateColor(
hasScrolledToTop.get(),
[0, 1],
[t.atoms.border_contrast_medium.borderColor, 'transparent'],
),
}
})
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: interpolateColor(
hasScrolledToBottom.get(),
[0, 1],
[t.atoms.border_contrast_medium.borderColor, 'transparent'],
),
}
})
return {
scrollHandler,
onScrollViewContentSizeChange,
onScrollViewLayout,
topBarAnimatedStyle,
bottomBarAnimatedStyle,
}
}
function useKeyboardVerticalOffset() {
const insets = useSafeAreaInsets()
// the keyboardavoidingview has bottom padding to avoid being obscured by the safe area when keyboard is closed.
// however, this leads to a gap when the keyboard is open. we account for that by subtracting the bottom inset when open.
let keyboardVerticalOffset = insets.bottom * -1
// iOS requires a bit of extra offset to account for the native sheet not being at the top of the screen
if (IS_IOS) {
// they ditched the gap behaviour on 26
if (IS_LIQUID_GLASS) {
keyboardVerticalOffset += insets.top
}
// iPhone SE
else if (insets.top === 20) {
keyboardVerticalOffset += 40
}
// all other iPhones on <26
else {
keyboardVerticalOffset += insets.top + 10
}
}
return keyboardVerticalOffset
}
async function whenAppViewReady(
agent: AtpAgent,
uri: string,
fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.unspecced.getPostThreadV2({
anchor: uri,
above: false,
below: 0,
branchingFactor: 0,
}),
)
}
function isEmptyPost(post: PostDraft) {
return (
post.richtext.text.trim().length === 0 &&
!post.embed.media &&
!post.embed.link &&
!post.embed.quote
)
}
function useHideKeyboardOnBackground() {
const appState = useAppState()
useEffect(() => {
if (IS_IOS) {
if (appState === 'inactive') {
Keyboard.dismiss()
}
}
}, [appState])
}
const styles = StyleSheet.create({
postBtn: {
borderRadius: 20,
paddingHorizontal: 20,
paddingVertical: 6,
marginLeft: 12,
},
stickyFooterWeb: web({
position: 'sticky',
bottom: 0,
}),
errorLine: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.red1,
borderRadius: 6,
marginHorizontal: 16,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 8,
},
reminderLine: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 6,
marginHorizontal: 16,
paddingHorizontal: 8,
paddingVertical: 6,
marginBottom: 8,
},
errorIcon: {
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.red4,
color: colors.red4,
borderRadius: 30,
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
marginRight: 5,
},
inactivePost: {
opacity: 0.5,
},
addExtLinkBtn: {
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 10,
marginBottom: 4,
},
})
function ErrorBanner({
error: standardError,
videoState,
clearError,
clearVideo,
}: {
error: string
videoState: VideoState | NoVideoState
clearError: () => void
clearVideo: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const videoError =
videoState.status === 'error' ? videoState.error : undefined
const error = standardError || videoError
const onClearError = () => {
if (standardError) {
clearError()
} else {
clearVideo()
}
}
if (!error) return null
return (
<Animated.View
style={[a.px_lg, a.pb_sm]}
entering={FadeIn}
exiting={FadeOut}>
<View
style={[
a.px_md,
a.py_sm,
a.gap_xs,
a.rounded_sm,
t.atoms.bg_contrast_25,
]}>
<View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
<CircleInfoIcon fill={t.palette.negative_400} />
<Text style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
{error}
</Text>
<Button
label={l`Dismiss error`}
size="tiny"
color="secondary"
variant="ghost"
shape="round"
style={[a.absolute, {top: 0, right: 0}]}
onPress={onClearError}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
{videoError && videoState.jobId && (
<Text
style={[
{paddingLeft: 28},
a.text_xs,
a.font_semi_bold,
a.leading_snug,
t.atoms.text_contrast_low,
]}>
<Trans>Job ID: {videoState.jobId}</Trans>
</Text>
)}
</View>
</Animated.View>
)
}
function ToolbarWrapper({
style,
children,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
}) {
if (IS_WEB) return children
return (
<Animated.View
style={style}
entering={FadeIn.duration(400)}
exiting={FadeOut.duration(400)}>
{children}
</Animated.View>
)
}
function VideoUploadToolbar({state}: {state: VideoState}) {
const t = useTheme()
const {t: l} = useLingui()
const progress = state.progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
const rotate = useDerivedValue(() => {
if (shouldRotate) {
return withRepeat(
withTiming(360, {
duration: 2500,
easing: Easing.out(Easing.cubic),
}),
-1,
)
}
return 0
})
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{rotateZ: `${rotate.get()}deg`}],
}
})
let text = ''
const isGif = state.video?.mimeType === 'image/gif'
switch (state.status) {
case 'compressing':
if (isGif) {
text = l`Compressing GIF...`
} else {
text = l`Compressing video...`
}
break
case 'uploading':
if (isGif) {
text = l`Uploading GIF...`
} else {
text = l`Uploading video...`
}
break
case 'processing':
if (isGif) {
text = l`Processing GIF...`
} else {
text = l`Processing video...`
}
break
case 'error':
text = l`Error`
wheelProgress = 100
break
case 'done':
if (isGif) {
text = l`GIF uploaded`
} else {
text = l`Video uploaded`
}
break
}
return (
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
<Animated.View style={[animatedStyle]}>
<ProgressCircle
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={
state.status === 'error'
? t.palette.negative_500
: t.palette.primary_500
}
progress={wheelProgress}
/>
</Animated.View>
<Text style={[a.font_semi_bold, a.ml_sm]}>{text}</Text>
</ToolbarWrapper>
)
}