Skip empty posts when publishing threads (#10307)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user