Skip empty posts when publishing threads (#10307)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-04-20 09:33:38 -07:00
committed by GitHub
parent 444c5787c5
commit b8cabfaae6
+75 -16
View File
@@ -207,6 +207,8 @@ export const ComposePost = ({
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()
@@ -783,16 +785,47 @@ export const ComposePost = ({
const canPost =
!missingAltError &&
thread.posts.some(post => !isEmptyPost(post)) &&
thread.posts.every(
post =>
post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
!isEmptyPost(post) &&
!(
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'error'
),
isEmptyPost(post) ||
(post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
!(
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'error'
)),
)
const getFilteredThread = (): {
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,
}
}
const onPressPublish = useCallback(async () => {
if (isPublishing) {
return
@@ -802,8 +835,15 @@ export const ComposePost = ({
return
}
const {type: emptyType, filteredThread} = getFilteredThread()
if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) {
emptyPostsPromptControl.open()
return
}
if (
thread.posts.some(
filteredThread.posts.some(
post =>
post.embed.media?.type === 'video' &&
post.embed.media.video.asset &&
@@ -814,6 +854,7 @@ export const ComposePost = ({
return
}
skipEmptyConfirmedRef.current = false
setError('')
setIsPublishing(true)
@@ -826,7 +867,7 @@ export const ComposePost = ({
agent,
queryClient,
{
thread,
thread: filteredThread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: currentLanguages,
@@ -857,10 +898,10 @@ export const ComposePost = ({
const res = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: postUri!,
above: false,
below: thread.posts.length - 1,
below: filteredThread.posts.length - 1,
branchingFactor: 1,
})
if (res.data.thread.length !== thread.posts.length) {
if (res.data.thread.length !== filteredThread.posts.length) {
throw new Error(`composer: app view is not ready`)
}
if (
@@ -887,7 +928,9 @@ export const ComposePost = ({
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
hasImages: filteredThread.posts.some(
p => p.embed.media?.type === 'images',
),
})
let err = cleanError(e.message)
@@ -902,14 +945,14 @@ export const ComposePost = ({
} finally {
if (postUri) {
let index = 0
for (let post of thread.posts) {
for (let post of filteredThread.posts) {
ax.metric('post:create', {
imageCount:
post.embed.media?.type === 'images'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: thread.posts.length > 1,
isPartOfThread: filteredThread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: fromPostLanguages(currentLanguages),
@@ -918,9 +961,9 @@ export const ComposePost = ({
index++
}
}
if (thread.posts.length > 1) {
if (filteredThread.posts.length > 1) {
ax.metric('thread:create', {
postCount: thread.posts.length,
postCount: filteredThread.posts.length,
isReply: !!replyTo,
})
}
@@ -973,7 +1016,7 @@ export const ComposePost = ({
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
{thread.posts.length > 1
{filteredThread.posts.length > 1
? l`Your posts were sent`
: replyTo
? l`Your reply was sent`
@@ -1016,8 +1059,14 @@ export const ComposePost = ({
composerState.isDirty,
cleanupPublishedDraft,
loadedDraftCreatedAt,
emptyPostsPromptControl,
])
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(() => {
@@ -1029,6 +1078,7 @@ export const ComposePost = ({
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') {
@@ -1268,6 +1318,15 @@ export const ComposePost = ({
</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>
)