Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

feat: convert long post to thread in composer

probably should get an icon (especially on mobile where it overflows) - in that case, a tooltip would be good!
https://tangled.org/jollywhoppers.com/witchsky.app/issues/28 requested here. I have not tested! I'm eepy.

xan.lol 343ad297 748d0b2f

+262 -7
+190 -7
src/view/com/composer/Composer.tsx
··· 54 54 type AppBskyUnspeccedGetPostThreadV2, 55 55 AtUri, 56 56 type BskyAgent, 57 - type RichText, 57 + RichText, 58 58 } from '@atproto/api' 59 59 import {msg, plural} from '@lingui/core/macro' 60 60 import {useLingui} from '@lingui/react' ··· 313 313 [activePost.id], 314 314 ) 315 315 316 + const onConvertActiveOverflowToThread = useCallback(() => { 317 + const splitPosts = splitOverflowPostIntoThreadTexts( 318 + activePost.richtext.text, 319 + ) 320 + if (splitPosts.length < 2) { 321 + return 322 + } 323 + 324 + setError('') 325 + composerDispatch({ 326 + type: 'replace_post_with_thread', 327 + postId: activePost.id, 328 + texts: splitPosts, 329 + }) 330 + }, [activePost.id, activePost.richtext.text, composerDispatch]) 331 + 316 332 const selectVideo = useCallback( 317 333 (postId: string, asset: ImagePickerAsset) => { 318 334 const abortController = new AbortController() ··· 1146 1162 currentLanguages={currentLanguages} 1147 1163 onSelectLanguage={onSelectLanguage} 1148 1164 openGallery={openGallery} 1165 + onConvertOverLimitToThread={onConvertActiveOverflowToThread} 1149 1166 /> 1150 1167 </> 1151 1168 ) ··· 1764 1781 <Pressable 1765 1782 accessibilityRole="button" 1766 1783 accessibilityLabel={_(msg`Generate Alt Text with AI`)} 1767 - accessibilityHint="" 1784 + accessibilityHint={_( 1785 + msg`Automatically generate alt text for images in this post using AI. Requires configured OpenRouter API key.`, 1786 + )} 1768 1787 onPress={handleGenerateAltText} 1769 1788 disabled={isGenerating}> 1770 1789 {isGenerating ? ( 1771 1790 <ActivityIndicator size="small" color={t.palette.primary_500} /> 1772 1791 ) : ( 1773 1792 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 1774 - <Trans>Generate with Ai</Trans> 1793 + <Trans>Generate alt text</Trans> 1775 1794 </Text> 1776 1795 )} 1777 1796 </Pressable> ··· 1983 2002 currentLanguages, 1984 2003 onSelectLanguage, 1985 2004 openGallery, 2005 + onConvertOverLimitToThread, 1986 2006 }: { 1987 2007 post: PostDraft 1988 2008 dispatch: (action: PostAction) => void ··· 1994 2014 currentLanguages: string[] 1995 2015 onSelectLanguage?: (language: string) => void 1996 2016 openGallery?: boolean 2017 + onConvertOverLimitToThread: () => void 1997 2018 }) { 1998 2019 const t = useTheme() 1999 2020 const {_} = useLingui() ··· 2011 2032 const video = media?.type === 'video' ? media.video : null 2012 2033 const isMaxImages = images.length >= MAX_IMAGES 2013 2034 const isMaxVideos = !!video 2035 + const isOverLimit = post.shortenedGraphemeLength > MAX_GRAPHEME_LENGTH 2014 2036 2015 2037 let selectedAssetsCount = 0 2016 2038 let isMediaSelectionDisabled = false ··· 2091 2113 }, 2092 2114 [post.id, onSelectVideo, onImageAdd], 2093 2115 ) 2116 + 2117 + const onPressConvertToThread = useCallback(() => { 2118 + onConvertOverLimitToThread() 2119 + }, [onConvertOverLimitToThread]) 2094 2120 2095 2121 return ( 2096 2122 <View ··· 2154 2180 currentLanguages={currentLanguages} 2155 2181 onSelectLanguage={onSelectLanguage} 2156 2182 /> 2157 - <CharProgress 2158 - count={post.shortenedGraphemeLength} 2159 - style={{width: 65}} 2160 - /> 2183 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 2184 + {isOverLimit && ( 2185 + <Pressable 2186 + accessibilityRole="button" 2187 + accessibilityLabel={_(msg`Convert long post into thread`)} 2188 + accessibilityHint={_( 2189 + msg`Splits your post into a thread and appends numbering`, 2190 + )} 2191 + onPress={onPressConvertToThread}> 2192 + <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 2193 + <Trans>Convert to thread</Trans> 2194 + </Text> 2195 + </Pressable> 2196 + )} 2197 + 2198 + <Pressable 2199 + accessibilityRole={isOverLimit ? 'button' : undefined} 2200 + accessibilityLabel={ 2201 + isOverLimit 2202 + ? _(msg`Convert long post into thread`) 2203 + : _(msg`Character count`) 2204 + } 2205 + accessibilityHint={_(msg`Shows character count for your post`)} 2206 + onPress={isOverLimit ? onPressConvertToThread : undefined}> 2207 + <CharProgress 2208 + count={post.shortenedGraphemeLength} 2209 + style={{width: 65}} 2210 + /> 2211 + </Pressable> 2212 + </View> 2161 2213 </View> 2162 2214 </View> 2163 2215 ) ··· 2343 2395 !post.embed.link && 2344 2396 !post.embed.quote 2345 2397 ) 2398 + } 2399 + 2400 + function splitOverflowPostIntoThreadTexts( 2401 + text: string, 2402 + maxLength = MAX_GRAPHEME_LENGTH, 2403 + ): string[] { 2404 + const trimmed = text.trim() 2405 + if (!trimmed) return [] 2406 + 2407 + const words = trimmed.split(/\s+/) 2408 + if (words.length < 2) return [] 2409 + 2410 + const firstWord = words[0] 2411 + const rest = words.slice(1) 2412 + 2413 + let totalGuess = 2 2414 + let parts: string[] = [] 2415 + 2416 + // Suffix length changes with total digit count; converge on a stable total. 2417 + for (let i = 0; i < 10; i++) { 2418 + parts = splitIntoThreadParts({ 2419 + firstWord, 2420 + rest, 2421 + total: totalGuess, 2422 + maxLength, 2423 + }) 2424 + if (parts.length <= 1) { 2425 + return [] 2426 + } 2427 + if (parts.length === totalGuess) { 2428 + break 2429 + } 2430 + totalGuess = parts.length 2431 + } 2432 + 2433 + const total = parts.length 2434 + const numbered = parts.map((part, idx) => `${part} (${idx + 1}/${total})`) 2435 + 2436 + if (numbered.some(part => getGraphemeLength(part) > maxLength)) { 2437 + return [] 2438 + } 2439 + 2440 + return numbered 2441 + } 2442 + 2443 + function splitIntoThreadParts({ 2444 + firstWord, 2445 + rest, 2446 + total, 2447 + maxLength, 2448 + }: { 2449 + firstWord: string 2450 + rest: string[] 2451 + total: number 2452 + maxLength: number 2453 + }): string[] { 2454 + const firstPart = `${firstWord} 🧵` 2455 + const firstLimit = getPartContentLimit(1, total, maxLength) 2456 + if (getGraphemeLength(firstPart) > firstLimit) { 2457 + return [] 2458 + } 2459 + 2460 + const parts = [firstPart] 2461 + 2462 + for (const originalWord of rest) { 2463 + let word = originalWord 2464 + 2465 + if (parts.length === 1) { 2466 + // Keep post 1 fixed as "<first word> 🧵". 2467 + parts.push('') 2468 + } 2469 + 2470 + while (word.length > 0) { 2471 + const partNumber = parts.length 2472 + const limit = getPartContentLimit(partNumber, total, maxLength) 2473 + if (limit <= 0) { 2474 + return parts 2475 + } 2476 + 2477 + const current = parts[partNumber - 1] 2478 + const next = current ? `${current} ${word}` : word 2479 + if (getGraphemeLength(next) <= limit) { 2480 + parts[partNumber - 1] = next 2481 + break 2482 + } 2483 + 2484 + if (current) { 2485 + parts.push('') 2486 + continue 2487 + } 2488 + 2489 + // If a single word exceeds the limit, hard-wrap it by grapheme. 2490 + const [head, tail] = splitAtGrapheme(word, limit) 2491 + if (!head) { 2492 + return parts 2493 + } 2494 + parts[partNumber - 1] = head 2495 + word = tail 2496 + if (word.length > 0) { 2497 + parts.push('') 2498 + } 2499 + } 2500 + } 2501 + 2502 + return parts.filter(Boolean) 2503 + } 2504 + 2505 + function getPartContentLimit( 2506 + partNumber: number, 2507 + total: number, 2508 + maxLength: number, 2509 + ) { 2510 + return maxLength - getGraphemeLength(` (${partNumber}/${total})`) 2511 + } 2512 + 2513 + function splitAtGrapheme(text: string, limit: number): [string, string] { 2514 + if (limit <= 0) return ['', text] 2515 + const graphemes = splitGraphemes(text) 2516 + return [graphemes.slice(0, limit).join(''), graphemes.slice(limit).join('')] 2517 + } 2518 + 2519 + function getGraphemeLength(text: string): number { 2520 + return new RichText({text}).graphemeLength 2521 + } 2522 + 2523 + function splitGraphemes(text: string): string[] { 2524 + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { 2525 + const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'}) 2526 + return Array.from(segmenter.segment(text), segment => segment.segment) 2527 + } 2528 + return Array.from(text) 2346 2529 } 2347 2530 2348 2531 function useHideKeyboardOnBackground() {
+72
src/view/com/composer/state/composer.ts
··· 125 125 type: 'add_post' 126 126 } 127 127 | { 128 + type: 'replace_post_with_thread' 129 + postId: string 130 + texts: string[] 131 + } 132 + | { 128 133 type: 'remove_post' 129 134 postId: string 130 135 } ··· 220 225 return { 221 226 ...state, 222 227 isDirty: true, 228 + thread: { 229 + ...state.thread, 230 + posts: nextPosts, 231 + }, 232 + } 233 + } 234 + case 'replace_post_with_thread': { 235 + const {postId, texts} = action 236 + if (texts.length === 0) { 237 + return state 238 + } 239 + 240 + const postIndex = state.thread.posts.findIndex(p => p.id === postId) 241 + if (postIndex === -1) { 242 + return state 243 + } 244 + 245 + const postToReplace = state.thread.posts[postIndex] 246 + const replacementPosts = texts.map((text, index) => 247 + createPostDraftFromText(text, { 248 + labels: index === 0 ? postToReplace.labels : [], 249 + embed: 250 + index === 0 251 + ? postToReplace.embed 252 + : { 253 + quote: undefined, 254 + media: undefined, 255 + link: undefined, 256 + }, 257 + }), 258 + ) 259 + 260 + const nextPosts = [ 261 + ...state.thread.posts.slice(0, postIndex), 262 + ...replacementPosts, 263 + ...state.thread.posts.slice(postIndex + 1), 264 + ] 265 + 266 + return { 267 + ...state, 268 + isDirty: true, 269 + activePostIndex: postIndex, 270 + mutableNeedsFocusActive: true, 223 271 thread: { 224 272 ...state.thread, 225 273 posts: nextPosts, ··· 727 775 newRt.detectFacetsWithoutResolution() 728 776 return shortenLinks(newRt).graphemeLength 729 777 } 778 + 779 + function createPostDraftFromText( 780 + text: string, 781 + overrides?: { 782 + id?: string 783 + labels?: SelfLabel[] 784 + embed?: EmbedDraft 785 + }, 786 + ): PostDraft { 787 + const richtext = new RichText({text}) 788 + richtext.detectFacetsWithoutResolution() 789 + 790 + return { 791 + id: overrides?.id ?? nanoid(), 792 + richtext, 793 + shortenedGraphemeLength: getShortenedLength(richtext), 794 + labels: overrides?.labels ?? [], 795 + embed: overrides?.embed ?? { 796 + quote: undefined, 797 + media: undefined, 798 + link: undefined, 799 + }, 800 + } 801 + }