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 setLangPrefs = useLanguagePrefsApi()
const textInputRef = useRef<TextInputRef>(null) const textInputRef = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const emptyPostsPromptControl = Prompt.usePromptControl()
const skipEmptyConfirmedRef = useRef(false)
const {mutateAsync: saveDraft, isPending: _isSavingDraft} = const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
useSaveDraftMutation() useSaveDraftMutation()
const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation() const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
@@ -783,16 +785,47 @@ export const ComposePost = ({
const canPost = const canPost =
!missingAltError && !missingAltError &&
thread.posts.some(post => !isEmptyPost(post)) &&
thread.posts.every( thread.posts.every(
post => post =>
post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && isEmptyPost(post) ||
!isEmptyPost(post) && (post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
!( !(
post.embed.media?.type === 'video' && post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'error' 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 () => { const onPressPublish = useCallback(async () => {
if (isPublishing) { if (isPublishing) {
return return
@@ -802,8 +835,15 @@ export const ComposePost = ({
return return
} }
const {type: emptyType, filteredThread} = getFilteredThread()
if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) {
emptyPostsPromptControl.open()
return
}
if ( if (
thread.posts.some( filteredThread.posts.some(
post => post =>
post.embed.media?.type === 'video' && post.embed.media?.type === 'video' &&
post.embed.media.video.asset && post.embed.media.video.asset &&
@@ -814,6 +854,7 @@ export const ComposePost = ({
return return
} }
skipEmptyConfirmedRef.current = false
setError('') setError('')
setIsPublishing(true) setIsPublishing(true)
@@ -826,7 +867,7 @@ export const ComposePost = ({
agent, agent,
queryClient, queryClient,
{ {
thread, thread: filteredThread,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
onStateChange: setPublishingStage, onStateChange: setPublishingStage,
langs: currentLanguages, langs: currentLanguages,
@@ -857,10 +898,10 @@ export const ComposePost = ({
const res = await agent.app.bsky.unspecced.getPostThreadV2({ const res = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: postUri!, anchor: postUri!,
above: false, above: false,
below: thread.posts.length - 1, below: filteredThread.posts.length - 1,
branchingFactor: 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`) throw new Error(`composer: app view is not ready`)
} }
if ( if (
@@ -887,7 +928,9 @@ export const ComposePost = ({
} catch (e: any) { } catch (e: any) {
logger.error(e, { logger.error(e, {
message: `Composer: create post failed`, 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) let err = cleanError(e.message)
@@ -902,14 +945,14 @@ export const ComposePost = ({
} finally { } finally {
if (postUri) { if (postUri) {
let index = 0 let index = 0
for (let post of thread.posts) { for (let post of filteredThread.posts) {
ax.metric('post:create', { ax.metric('post:create', {
imageCount: imageCount:
post.embed.media?.type === 'images' post.embed.media?.type === 'images'
? post.embed.media.images.length ? post.embed.media.images.length
: 0, : 0,
isReply: index > 0 || !!replyTo, isReply: index > 0 || !!replyTo,
isPartOfThread: thread.posts.length > 1, isPartOfThread: filteredThread.posts.length > 1,
hasLink: !!post.embed.link, hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote, hasQuote: !!post.embed.quote,
langs: fromPostLanguages(currentLanguages), langs: fromPostLanguages(currentLanguages),
@@ -918,9 +961,9 @@ export const ComposePost = ({
index++ index++
} }
} }
if (thread.posts.length > 1) { if (filteredThread.posts.length > 1) {
ax.metric('thread:create', { ax.metric('thread:create', {
postCount: thread.posts.length, postCount: filteredThread.posts.length,
isReply: !!replyTo, isReply: !!replyTo,
}) })
} }
@@ -973,7 +1016,7 @@ export const ComposePost = ({
<Toast.Outer> <Toast.Outer>
<Toast.Icon /> <Toast.Icon />
<Toast.Text> <Toast.Text>
{thread.posts.length > 1 {filteredThread.posts.length > 1
? l`Your posts were sent` ? l`Your posts were sent`
: replyTo : replyTo
? l`Your reply was sent` ? l`Your reply was sent`
@@ -1016,8 +1059,14 @@ export const ComposePost = ({
composerState.isDirty, composerState.isDirty,
cleanupPublishedDraft, cleanupPublishedDraft,
loadedDraftCreatedAt, loadedDraftCreatedAt,
emptyPostsPromptControl,
]) ])
const handleConfirmSkipEmpty = () => {
skipEmptyConfirmedRef.current = true
void onPressPublish()
}
// Preserves the referential identity passed to each post item. // Preserves the referential identity passed to each post item.
// Avoids re-rendering all posts on each keystroke. // Avoids re-rendering all posts on each keystroke.
const onComposerPostPublish = useNonReactiveCallback(() => { const onComposerPostPublish = useNonReactiveCallback(() => {
@@ -1029,6 +1078,7 @@ export const ComposePost = ({
let erroredVideos = 0 let erroredVideos = 0
let uploadingVideos = 0 let uploadingVideos = 0
for (let post of thread.posts) { for (let post of thread.posts) {
if (isEmptyPost(post)) continue
if (post.embed.media?.type === 'video') { if (post.embed.media?.type === 'video') {
const video = post.embed.media.video const video = post.embed.media.video
if (video.status === 'error') { if (video.status === 'error') {
@@ -1268,6 +1318,15 @@ export const ComposePost = ({
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </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> </KeyboardAvoidingView>
</BottomSheetPortalProvider> </BottomSheetPortalProvider>
) )