Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Skip empty posts when publishing threads (#10307)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

Samuel Newman
Claude Opus 4.6 (1M context)
and committed by
GitHub
b8cabfaa 444c5787

+75 -16
+75 -16
src/view/com/composer/Composer.tsx
··· 207 207 const setLangPrefs = useLanguagePrefsApi() 208 208 const textInputRef = useRef<TextInputRef>(null) 209 209 const discardPromptControl = Prompt.usePromptControl() 210 + const emptyPostsPromptControl = Prompt.usePromptControl() 211 + const skipEmptyConfirmedRef = useRef(false) 210 212 const {mutateAsync: saveDraft, isPending: _isSavingDraft} = 211 213 useSaveDraftMutation() 212 214 const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation() ··· 783 785 784 786 const canPost = 785 787 !missingAltError && 788 + thread.posts.some(post => !isEmptyPost(post)) && 786 789 thread.posts.every( 787 790 post => 788 - post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && 789 - !isEmptyPost(post) && 790 - !( 791 - post.embed.media?.type === 'video' && 792 - post.embed.media.video.status === 'error' 793 - ), 791 + isEmptyPost(post) || 792 + (post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && 793 + !( 794 + post.embed.media?.type === 'video' && 795 + post.embed.media.video.status === 'error' 796 + )), 797 + ) 798 + 799 + const getFilteredThread = (): { 800 + type: 'none' | 'trailing-only' | 'non-trailing' 801 + filteredThread: ThreadDraft 802 + } => { 803 + const nonEmptyPosts = thread.posts.filter(post => !isEmptyPost(post)) 804 + 805 + if (nonEmptyPosts.length === thread.posts.length) { 806 + return {type: 'none', filteredThread: thread} 807 + } 808 + 809 + let lastNonEmptyIndex = -1 810 + for (let i = thread.posts.length - 1; i >= 0; i--) { 811 + if (!isEmptyPost(thread.posts[i])) { 812 + lastNonEmptyIndex = i 813 + break 814 + } 815 + } 816 + 817 + const hasNonTrailingEmpty = thread.posts.some( 818 + (post, i) => i < lastNonEmptyIndex && isEmptyPost(post), 794 819 ) 795 820 821 + const filteredThread: ThreadDraft = {...thread, posts: nonEmptyPosts} 822 + 823 + return { 824 + type: hasNonTrailingEmpty ? 'non-trailing' : 'trailing-only', 825 + filteredThread, 826 + } 827 + } 828 + 796 829 const onPressPublish = useCallback(async () => { 797 830 if (isPublishing) { 798 831 return ··· 802 835 return 803 836 } 804 837 838 + const {type: emptyType, filteredThread} = getFilteredThread() 839 + 840 + if (emptyType === 'non-trailing' && !skipEmptyConfirmedRef.current) { 841 + emptyPostsPromptControl.open() 842 + return 843 + } 844 + 805 845 if ( 806 - thread.posts.some( 846 + filteredThread.posts.some( 807 847 post => 808 848 post.embed.media?.type === 'video' && 809 849 post.embed.media.video.asset && ··· 814 854 return 815 855 } 816 856 857 + skipEmptyConfirmedRef.current = false 817 858 setError('') 818 859 setIsPublishing(true) 819 860 ··· 826 867 agent, 827 868 queryClient, 828 869 { 829 - thread, 870 + thread: filteredThread, 830 871 replyTo: replyTo?.uri, 831 872 onStateChange: setPublishingStage, 832 873 langs: currentLanguages, ··· 857 898 const res = await agent.app.bsky.unspecced.getPostThreadV2({ 858 899 anchor: postUri!, 859 900 above: false, 860 - below: thread.posts.length - 1, 901 + below: filteredThread.posts.length - 1, 861 902 branchingFactor: 1, 862 903 }) 863 - if (res.data.thread.length !== thread.posts.length) { 904 + if (res.data.thread.length !== filteredThread.posts.length) { 864 905 throw new Error(`composer: app view is not ready`) 865 906 } 866 907 if ( ··· 887 928 } catch (e: any) { 888 929 logger.error(e, { 889 930 message: `Composer: create post failed`, 890 - hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), 931 + hasImages: filteredThread.posts.some( 932 + p => p.embed.media?.type === 'images', 933 + ), 891 934 }) 892 935 893 936 let err = cleanError(e.message) ··· 902 945 } finally { 903 946 if (postUri) { 904 947 let index = 0 905 - for (let post of thread.posts) { 948 + for (let post of filteredThread.posts) { 906 949 ax.metric('post:create', { 907 950 imageCount: 908 951 post.embed.media?.type === 'images' 909 952 ? post.embed.media.images.length 910 953 : 0, 911 954 isReply: index > 0 || !!replyTo, 912 - isPartOfThread: thread.posts.length > 1, 955 + isPartOfThread: filteredThread.posts.length > 1, 913 956 hasLink: !!post.embed.link, 914 957 hasQuote: !!post.embed.quote, 915 958 langs: fromPostLanguages(currentLanguages), ··· 918 961 index++ 919 962 } 920 963 } 921 - if (thread.posts.length > 1) { 964 + if (filteredThread.posts.length > 1) { 922 965 ax.metric('thread:create', { 923 - postCount: thread.posts.length, 966 + postCount: filteredThread.posts.length, 924 967 isReply: !!replyTo, 925 968 }) 926 969 } ··· 973 1016 <Toast.Outer> 974 1017 <Toast.Icon /> 975 1018 <Toast.Text> 976 - {thread.posts.length > 1 1019 + {filteredThread.posts.length > 1 977 1020 ? l`Your posts were sent` 978 1021 : replyTo 979 1022 ? l`Your reply was sent` ··· 1016 1059 composerState.isDirty, 1017 1060 cleanupPublishedDraft, 1018 1061 loadedDraftCreatedAt, 1062 + emptyPostsPromptControl, 1019 1063 ]) 1020 1064 1065 + const handleConfirmSkipEmpty = () => { 1066 + skipEmptyConfirmedRef.current = true 1067 + void onPressPublish() 1068 + } 1069 + 1021 1070 // Preserves the referential identity passed to each post item. 1022 1071 // Avoids re-rendering all posts on each keystroke. 1023 1072 const onComposerPostPublish = useNonReactiveCallback(() => { ··· 1029 1078 let erroredVideos = 0 1030 1079 let uploadingVideos = 0 1031 1080 for (let post of thread.posts) { 1081 + if (isEmptyPost(post)) continue 1032 1082 if (post.embed.media?.type === 'video') { 1033 1083 const video = post.embed.media.video 1034 1084 if (video.status === 'error') { ··· 1268 1318 </Prompt.Actions> 1269 1319 </Prompt.Outer> 1270 1320 )} 1321 + 1322 + <Prompt.Basic 1323 + control={emptyPostsPromptControl} 1324 + title={l`Skip empty posts?`} 1325 + description={l`Your thread has empty posts that will be skipped. The remaining posts will be published as a thread.`} 1326 + confirmButtonCta={l`Post anyway`} 1327 + cancelButtonCta={l`Keep editing`} 1328 + onConfirm={handleConfirmSkipEmpty} 1329 + /> 1271 1330 </KeyboardAvoidingView> 1272 1331 </BottomSheetPortalProvider> 1273 1332 )