From b8cabfaae6bbd1825772421095097bff45d88d48 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 20 Apr 2026 09:33:38 -0700 Subject: [PATCH] Skip empty posts when publishing threads (#10307) Co-authored-by: Claude Opus 4.6 (1M context) --- src/view/com/composer/Composer.tsx | 91 ++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index d12b3a0bea..2801239a81 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -207,6 +207,8 @@ export const ComposePost = ({ const setLangPrefs = useLanguagePrefsApi() const textInputRef = useRef(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 = ({ - {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 = ({ )} + + )