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 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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user