Files
bsky-social-app/src/view/com/composer/Composer.tsx
T
vineyardbovines a289bc153c Add video upload telemetry events (APP-2458)
Instruments the video upload pipeline with a typed event family
(picked, compress{Started,Completed,Skipped,Failed}, upload{Started,Completed,Failed},
processing{Started,Completed,Failed}, published, abandoned) plus a parent
Sentry span with child spans per phase. Every event carries an uploadId,
engine identifier, and jobId once known.

Implements the Metrics workstream from the Video v2 frontend spec (§D):
fleet visibility into per-phase timings, codec paths, file-size
distributions, and abandonment phase. No content is captured — sizes,
codecs, dimensions, timings, and error class names only.

Telemetry is created alongside the abort controller in Composer.tsx and
threaded through the composer reducer onto VideoState so onPressPublish
can fire the published event once apilib.post resolves. The abort signal
drives the abandoned event with the phase the user bailed in.
2026-06-23 09:45:50 -04:00

2655 lines
76 KiB
TypeScript

import {
Fragment,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import {
ActivityIndicator,
BackHandler,
Keyboard,
KeyboardAvoidingView,
type LayoutChangeEvent,
ScrollView,
type StyleProp,
StyleSheet,
View,
type ViewStyle,
} from 'react-native'
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
type AnimatedRef,
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,
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
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/pickVideo'
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 [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
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(
(postId: string, asset: ImagePickerAsset) => {
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,
},
})
void processVideo(
asset,
videoAction => {
composerDispatch({
type: 'update_post',
postId: postId,
postAction: {
type: 'embed_update_video',
videoAction,
},
})
},
agent,
currentDid,
abortController.signal,
i18n,
telemetry,
)
},
[i18n, agent, currentDid, composerDispatch, ax.metric],
)
const onInitVideo = useNonReactiveCallback(() => {
if (initVideoUri) {
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,
},
})
// 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,
})
}
},
[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
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:
// iOS - when keyboard is closed, keep the bottom bar in the safe area
(IS_IOS && !isKeyboardVisible) ||
// Android - Android >=35 KeyboardAvoidingView adds double padding when
// keyboard is closed, so we subtract that in the offset and add it back
// here when the keyboard is open
(IS_ANDROID && isKeyboardVisible)
? insets.bottom
: 0,
}),
[insets, isKeyboardVisible],
)
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 guard above (status !== 'done') 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: any) {
logger.info(`composer: waiting for app view failed`, {
safeMessage: waitErr,
})
}
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: filteredThread.posts.some(
p =>
p.embed.media?.type === 'images' ||
p.embed.media?.type === 'gallery',
),
})
let err = cleanError(e.message)
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={onPressPublish}
onSelectDraft={handleSelectDraft}
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={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
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}))
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={onPhotoPasted}
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: StyleProp<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: StyleProp<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
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.message,
})
})
onImageAdd(selectedImages)
} else if (type === 'video') {
onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
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 {top, bottom} = useSafeAreaInsets()
// Android etc
if (!IS_IOS) {
// need to account for the edge-to-edge nav bar
return bottom * -1
}
// they ditched the gap behaviour on 26
if (IS_LIQUID_GLASS) {
return top
}
// iPhone SE
if (top === 20) return 40
// all other iPhones on <26
return top + 10
}
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>
)
}