Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Thread composer UI (#6050)

* Basic adding of posts

* Switch active post on focus

* Conditionally show plus button

* Insert posts midthread

* Track active/inactive post

* Delete posts in a thread

* Focus after deletion

* Tweak empty post detection

* Mix height for active only

* Move toolbar with post on web

* Fix footer positioning

* Post All button

* Fix reply to positioning

* Improve memoization

* Improve memoization for clearVideo

* Remove unnecessary argument

* Add some manual memoization to fix re-renders

* Scroll to bottom on add new

* Fix opacity on Android

* Add backdrop

* Fix videos

* Check alt for video too

* Clear pending publish on error

* Fork alt message by type

* Separate placeholder for next posts

* Limit hitslop to avoid clashes

authored by

dan and committed by
GitHub
7a08d61d 01c9ac0e

+464 -216
+1 -1
package.json
··· 68 68 "@fortawesome/free-regular-svg-icons": "^6.1.1", 69 69 "@fortawesome/free-solid-svg-icons": "^6.1.1", 70 70 "@fortawesome/react-native-fontawesome": "^0.3.2", 71 - "@haileyok/bluesky-video": "0.2.3", 71 + "@haileyok/bluesky-video": "0.2.4", 72 72 "@ipld/dag-cbor": "^9.2.0", 73 73 "@lingui/react": "^4.5.0", 74 74 "@mattermost/react-native-paste-input": "^0.7.1",
+1
src/lib/constants.ts
··· 95 95 export const HITSLOP_20 = createHitslop(20) 96 96 export const HITSLOP_30 = createHitslop(30) 97 97 export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 98 + export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4} 98 99 export const BACK_HITSLOP = HITSLOP_30 99 100 export const MAX_POST_LINES = 25 100 101
+340 -198
src/view/com/composer/Composer.tsx
··· 28 28 interpolateColor, 29 29 LayoutAnimationConfig, 30 30 LinearTransition, 31 + runOnUI, 32 + scrollTo, 33 + useAnimatedRef, 31 34 useAnimatedStyle, 32 35 useDerivedValue, 33 36 useSharedValue, ··· 167 170 createComposerState, 168 171 ) 169 172 170 - // TODO: Display drafts for other posts in the thread. 171 173 const thread = composerState.thread 172 174 const activePost = thread.posts[composerState.activePostIndex] 175 + const nextPost: PostDraft | undefined = 176 + thread.posts[composerState.activePostIndex + 1] 173 177 const dispatch = useCallback( 174 178 (postAction: PostAction) => { 175 179 composerDispatch({ ··· 226 230 227 231 const clearVideo = React.useCallback( 228 232 (postId: string) => { 229 - const post = thread.posts.find(p => p.id === postId) 230 - const postMedia = post?.embed.media 231 - if (postMedia?.type === 'video') { 232 - postMedia.video.abortController.abort() 233 - composerDispatch({ 234 - type: 'update_post', 235 - postId: postId, 236 - postAction: { 237 - type: 'embed_remove_video', 238 - }, 239 - }) 240 - } 233 + composerDispatch({ 234 + type: 'update_post', 235 + postId: postId, 236 + postAction: { 237 + type: 'embed_remove_video', 238 + }, 239 + }) 241 240 }, 242 - [thread, composerDispatch], 241 + [composerDispatch], 243 242 ) 244 243 245 244 const [publishOnUpload, setPublishOnUpload] = useState(false) ··· 297 296 } 298 297 }, [onPressCancel, closeAllDialogs, closeAllModals]) 299 298 300 - const isAltTextRequiredAndMissing = useMemo(() => { 299 + const missingAltError = useMemo(() => { 301 300 if (!requireAltTextEnabled) { 302 - return false 301 + return 303 302 } 304 - return thread.posts.some(post => { 305 - const media = post.embed.media 303 + for (let i = 0; i < thread.posts.length; i++) { 304 + const media = thread.posts[i].embed.media 306 305 if (media) { 307 306 if (media.type === 'images' && media.images.some(img => !img.alt)) { 308 - return true 307 + return _(msg`One or more images is missing alt text.`) 309 308 } 310 309 if (media.type === 'gif' && !media.alt) { 311 - return true 310 + return _(msg`One or more GIFs is missing alt text.`) 311 + } 312 + if ( 313 + media.type === 'video' && 314 + media.video.status !== 'error' && 315 + !media.video.altText 316 + ) { 317 + return _(msg`One or more videos is missing alt text.`) 312 318 } 313 319 } 314 - }) 315 - }, [thread, requireAltTextEnabled]) 320 + } 321 + }, [thread, requireAltTextEnabled, _]) 316 322 317 323 const canPost = 318 - !isAltTextRequiredAndMissing && 324 + !missingAltError && 319 325 thread.posts.every( 320 326 post => 321 327 post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && 322 - !( 323 - post.richtext.text.trim().length === 0 && 324 - !post.embed.link && 325 - !post.embed.media && 326 - !post.embed.quote 327 - ) && 328 + !isEmptyPost(post) && 328 329 !( 329 330 post.embed.media?.type === 'video' && 330 331 post.embed.media.video.status === 'error' 331 332 ), 332 333 ) 333 334 334 - const onPressPublish = React.useCallback( 335 - async (finishedUploading: boolean) => { 336 - if (isPublishing) { 337 - return 338 - } 335 + const onPressPublish = React.useCallback(async () => { 336 + if (isPublishing) { 337 + return 338 + } 339 339 340 - if (!canPost) { 341 - return 342 - } 340 + if (!canPost) { 341 + return 342 + } 343 343 344 - if ( 345 - !finishedUploading && 346 - thread.posts.some( 347 - post => 348 - post.embed.media?.type === 'video' && 349 - post.embed.media.video.asset && 350 - post.embed.media.video.status !== 'done', 351 - ) 352 - ) { 353 - setPublishOnUpload(true) 354 - return 355 - } 344 + if ( 345 + thread.posts.some( 346 + post => 347 + post.embed.media?.type === 'video' && 348 + post.embed.media.video.asset && 349 + post.embed.media.video.status !== 'done', 350 + ) 351 + ) { 352 + setPublishOnUpload(true) 353 + return 354 + } 356 355 357 - setError('') 358 - setIsPublishing(true) 356 + setError('') 357 + setIsPublishing(true) 359 358 360 - let postUri 359 + let postUri 360 + try { 361 + postUri = ( 362 + await apilib.post(agent, queryClient, { 363 + thread, 364 + replyTo: replyTo?.uri, 365 + onStateChange: setPublishingStage, 366 + langs: toPostLanguages(langPrefs.postLanguage), 367 + }) 368 + ).uris[0] 361 369 try { 362 - postUri = ( 363 - await apilib.post(agent, queryClient, { 364 - thread, 365 - replyTo: replyTo?.uri, 366 - onStateChange: setPublishingStage, 367 - langs: toPostLanguages(langPrefs.postLanguage), 368 - }) 369 - ).uris[0] 370 - try { 371 - await whenAppViewReady(agent, postUri, res => { 372 - const postedThread = res.data.thread 373 - return AppBskyFeedDefs.isThreadViewPost(postedThread) 374 - }) 375 - } catch (waitErr: any) { 376 - logger.error(waitErr, { 377 - message: `Waiting for app view failed`, 378 - }) 379 - // Keep going because the post *was* published. 380 - } 381 - } catch (e: any) { 382 - logger.error(e, { 383 - message: `Composer: create post failed`, 384 - hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), 370 + await whenAppViewReady(agent, postUri, res => { 371 + const postedThread = res.data.thread 372 + return AppBskyFeedDefs.isThreadViewPost(postedThread) 373 + }) 374 + } catch (waitErr: any) { 375 + logger.error(waitErr, { 376 + message: `Waiting for app view failed`, 385 377 }) 378 + // Keep going because the post *was* published. 379 + } 380 + } catch (e: any) { 381 + logger.error(e, { 382 + message: `Composer: create post failed`, 383 + hasImages: thread.posts.some(p => p.embed.media?.type === 'images'), 384 + }) 386 385 387 - let err = cleanError(e.message) 388 - if (err.includes('not locate record')) { 389 - err = _( 390 - msg`We're sorry! The post you are replying to has been deleted.`, 391 - ) 392 - } else if (e instanceof EmbeddingDisabledError) { 393 - err = _(msg`This post's author has disabled quote posts.`) 394 - } 395 - setError(err) 396 - setIsPublishing(false) 397 - return 398 - } finally { 399 - if (postUri) { 400 - let index = 0 401 - for (let post of thread.posts) { 402 - logEvent('post:create', { 403 - imageCount: 404 - post.embed.media?.type === 'images' 405 - ? post.embed.media.images.length 406 - : 0, 407 - isReply: index > 0 || !!replyTo, 408 - hasLink: !!post.embed.link, 409 - hasQuote: !!post.embed.quote, 410 - langs: langPrefs.postLanguage, 411 - logContext: 'Composer', 412 - }) 413 - index++ 414 - } 415 - } 386 + let err = cleanError(e.message) 387 + if (err.includes('not locate record')) { 388 + err = _( 389 + msg`We're sorry! The post you are replying to has been deleted.`, 390 + ) 391 + } else if (e instanceof EmbeddingDisabledError) { 392 + err = _(msg`This post's author has disabled quote posts.`) 416 393 } 417 - if (postUri && !replyTo) { 418 - emitPostCreated() 419 - } 420 - setLangPrefs.savePostLanguageToHistory() 421 - if (initQuote) { 422 - // We want to wait for the quote count to update before we call `onPost`, which will refetch data 423 - whenAppViewReady(agent, initQuote.uri, res => { 424 - const quotedThread = res.data.thread 425 - if ( 426 - AppBskyFeedDefs.isThreadViewPost(quotedThread) && 427 - quotedThread.post.quoteCount !== initQuote.quoteCount 428 - ) { 429 - onPost?.(postUri) 430 - return true 431 - } 432 - return false 433 - }) 434 - } else { 435 - onPost?.(postUri) 394 + setError(err) 395 + setIsPublishing(false) 396 + return 397 + } finally { 398 + if (postUri) { 399 + let index = 0 400 + for (let post of thread.posts) { 401 + logEvent('post:create', { 402 + imageCount: 403 + post.embed.media?.type === 'images' 404 + ? post.embed.media.images.length 405 + : 0, 406 + isReply: index > 0 || !!replyTo, 407 + hasLink: !!post.embed.link, 408 + hasQuote: !!post.embed.quote, 409 + langs: langPrefs.postLanguage, 410 + logContext: 'Composer', 411 + }) 412 + index++ 413 + } 436 414 } 437 - onClose() 438 - Toast.show( 439 - replyTo 440 - ? _(msg`Your reply has been published`) 441 - : _(msg`Your post has been published`), 442 - ) 443 - }, 444 - [ 445 - _, 446 - agent, 447 - thread, 448 - canPost, 449 - isPublishing, 450 - langPrefs.postLanguage, 451 - onClose, 452 - onPost, 453 - initQuote, 454 - replyTo, 455 - setLangPrefs, 456 - queryClient, 457 - ], 458 - ) 415 + } 416 + if (postUri && !replyTo) { 417 + emitPostCreated() 418 + } 419 + setLangPrefs.savePostLanguageToHistory() 420 + if (initQuote) { 421 + // We want to wait for the quote count to update before we call `onPost`, which will refetch data 422 + whenAppViewReady(agent, initQuote.uri, res => { 423 + const quotedThread = res.data.thread 424 + if ( 425 + AppBskyFeedDefs.isThreadViewPost(quotedThread) && 426 + quotedThread.post.quoteCount !== initQuote.quoteCount 427 + ) { 428 + onPost?.(postUri) 429 + return true 430 + } 431 + return false 432 + }) 433 + } else { 434 + onPost?.(postUri) 435 + } 436 + onClose() 437 + Toast.show( 438 + replyTo 439 + ? _(msg`Your reply has been published`) 440 + : _(msg`Your post has been published`), 441 + ) 442 + }, [ 443 + _, 444 + agent, 445 + thread, 446 + canPost, 447 + isPublishing, 448 + langPrefs.postLanguage, 449 + onClose, 450 + onPost, 451 + initQuote, 452 + replyTo, 453 + setLangPrefs, 454 + queryClient, 455 + ]) 456 + 457 + // Preserves the referential identity passed to each post item. 458 + // Avoids re-rendering all posts on each keystroke. 459 + const onComposerPostPublish = useNonReactiveCallback(() => { 460 + onPressPublish() 461 + }) 459 462 460 463 React.useEffect(() => { 461 464 if (publishOnUpload) { 465 + let erroredVideos = 0 462 466 let uploadingVideos = 0 463 467 for (let post of thread.posts) { 464 468 if (post.embed.media?.type === 'video') { 465 469 const video = post.embed.media.video 466 - if (!video.pendingPublish) { 470 + if (video.status === 'error') { 471 + erroredVideos++ 472 + } else if (video.status !== 'done') { 467 473 uploadingVideos++ 468 474 } 469 475 } 470 476 } 471 - if (uploadingVideos === 0) { 477 + if (erroredVideos > 0) { 472 478 setPublishOnUpload(false) 473 - onPressPublish(true) 479 + } else if (uploadingVideos === 0) { 480 + setPublishOnUpload(false) 481 + onPressPublish() 474 482 } 475 483 } 476 484 }, [thread.posts, onPressPublish, publishOnUpload]) ··· 495 503 openEmojiPicker?.(textInput.current?.getCursorPosition()) 496 504 }, [openEmojiPicker]) 497 505 506 + const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 507 + useEffect(() => { 508 + if (composerState.mutableNeedsFocusActive) { 509 + composerState.mutableNeedsFocusActive = false 510 + textInput.current?.focus() 511 + } 512 + }, [composerState]) 513 + 498 514 const { 515 + contentHeight, 499 516 scrollHandler, 500 517 onScrollViewContentSizeChange, 501 518 onScrollViewLayout, 502 519 topBarAnimatedStyle, 503 520 bottomBarAnimatedStyle, 504 521 } = useAnimatedBorders() 522 + 523 + useEffect(() => { 524 + if (composerState.mutableNeedsScrollToBottom) { 525 + composerState.mutableNeedsScrollToBottom = false 526 + runOnUI(scrollTo)(scrollViewRef, 0, contentHeight.value, true) 527 + } 528 + }, [composerState, scrollViewRef, contentHeight]) 505 529 506 530 const keyboardVerticalOffset = useKeyboardVerticalOffset() 507 531 532 + const footer = ( 533 + <> 534 + <SuggestedLanguage text={activePost.richtext.text} /> 535 + <ComposerPills 536 + isReply={!!replyTo} 537 + post={activePost} 538 + thread={composerState.thread} 539 + dispatch={composerDispatch} 540 + bottomBarAnimatedStyle={bottomBarAnimatedStyle} 541 + /> 542 + <ComposerFooter 543 + post={activePost} 544 + dispatch={dispatch} 545 + showAddButton={ 546 + !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) 547 + } 548 + onError={setError} 549 + onEmojiButtonPress={onEmojiButtonPress} 550 + onSelectVideo={selectVideo} 551 + onAddPost={() => { 552 + composerDispatch({ 553 + type: 'add_post', 554 + }) 555 + }} 556 + /> 557 + </> 558 + ) 559 + 560 + const isFooterSticky = !isNative && thread.posts.length > 1 508 561 return ( 509 562 <BottomSheetPortalProvider> 510 563 <KeyboardAvoidingView ··· 521 574 isReply={!!replyTo} 522 575 isPublishQueued={publishOnUpload} 523 576 isPublishing={isPublishing} 577 + isThread={thread.posts.length > 1} 524 578 publishingStage={publishingStage} 525 579 topBarAnimatedStyle={topBarAnimatedStyle} 526 580 onCancel={onPressCancel} 527 - onPublish={() => onPressPublish(false)}> 528 - {isAltTextRequiredAndMissing && <AltTextReminder />} 581 + onPublish={onPressPublish}> 582 + {missingAltError && <AltTextReminder error={missingAltError} />} 529 583 <ErrorBanner 530 584 error={error} 531 585 videoState={erroredVideo} ··· 539 593 </ComposerTopBar> 540 594 541 595 <Animated.ScrollView 596 + ref={scrollViewRef} 542 597 layout={native(LinearTransition)} 543 598 onScroll={scrollHandler} 544 599 style={styles.scrollView} ··· 546 601 onContentSizeChange={onScrollViewContentSizeChange} 547 602 onLayout={onScrollViewLayout}> 548 603 {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 549 - <ComposerPost 550 - key={activePost.id} 551 - post={activePost} 552 - dispatch={composerDispatch} 553 - textInput={textInput} 554 - isReply={!!replyTo} 555 - canRemoveQuote={!initQuote} 556 - onSelectVideo={asset => selectVideo(activePost.id, asset)} 557 - onClearVideo={() => clearVideo(activePost.id)} 558 - onPublish={() => onPressPublish(false)} 559 - onError={setError} 560 - /> 604 + {thread.posts.map((post, index) => ( 605 + <React.Fragment key={post.id}> 606 + <ComposerPost 607 + post={post} 608 + dispatch={composerDispatch} 609 + textInput={post.id === activePost.id ? textInput : null} 610 + isFirstPost={index === 0} 611 + isReply={index > 0 || !!replyTo} 612 + isActive={post.id === activePost.id} 613 + canRemovePost={thread.posts.length > 1} 614 + canRemoveQuote={index > 0 || !initQuote} 615 + onSelectVideo={selectVideo} 616 + onClearVideo={clearVideo} 617 + onPublish={onComposerPostPublish} 618 + onError={setError} 619 + /> 620 + {isFooterSticky && post.id === activePost.id && footer} 621 + </React.Fragment> 622 + ))} 561 623 </Animated.ScrollView> 562 - 563 - <React.Fragment key={activePost.id}> 564 - <SuggestedLanguage text={activePost.richtext.text} /> 565 - <ComposerPills 566 - isReply={!!replyTo} 567 - post={activePost} 568 - thread={composerState.thread} 569 - dispatch={composerDispatch} 570 - bottomBarAnimatedStyle={bottomBarAnimatedStyle} 571 - /> 572 - <ComposerFooter 573 - post={activePost} 574 - dispatch={dispatch} 575 - onError={setError} 576 - onEmojiButtonPress={onEmojiButtonPress} 577 - onSelectVideo={asset => selectVideo(activePost.id, asset)} 578 - /> 579 - </React.Fragment> 624 + {!isFooterSticky && footer} 580 625 </View> 581 626 582 627 <Prompt.Basic ··· 592 637 ) 593 638 } 594 639 595 - function ComposerPost({ 640 + let ComposerPost = React.memo(function ComposerPost({ 596 641 post, 597 642 dispatch, 598 643 textInput, 644 + isActive, 599 645 isReply, 646 + isFirstPost, 647 + canRemovePost, 600 648 canRemoveQuote, 601 649 onClearVideo, 602 650 onSelectVideo, ··· 606 654 post: PostDraft 607 655 dispatch: (action: ComposerAction) => void 608 656 textInput: React.Ref<TextInputRef> 657 + isActive: boolean 609 658 isReply: boolean 659 + isFirstPost: boolean 660 + canRemovePost: boolean 610 661 canRemoveQuote: boolean 611 - onClearVideo: () => void 612 - onSelectVideo: (asset: ImagePickerAsset) => void 662 + onClearVideo: (postId: string) => void 663 + onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 613 664 onError: (error: string) => void 614 665 onPublish: (richtext: RichText) => void 615 666 }) { ··· 619 670 const {data: currentProfile} = useProfileQuery({did: currentDid}) 620 671 const richtext = post.richtext 621 672 const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media 622 - const forceMinHeight = isWeb && isTextOnly 673 + const forceMinHeight = isWeb && isTextOnly && isActive 623 674 const selectTextInputPlaceholder = isReply 624 - ? _(msg`Write your reply`) 675 + ? isFirstPost 676 + ? _(msg`Write your reply`) 677 + : _(msg`Add another post`) 625 678 : _(msg`What's up?`) 679 + const discardPromptControl = Prompt.usePromptControl() 626 680 627 681 const dispatchPost = useCallback( 628 682 (action: PostAction) => { ··· 655 709 const onPhotoPasted = useCallback( 656 710 async (uri: string) => { 657 711 if (uri.startsWith('data:video/')) { 658 - onSelectVideo({uri, type: 'video', height: 0, width: 0}) 712 + onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0}) 659 713 } else { 660 714 const res = await pasteImage(uri) 661 715 onImageAdd([res]) 662 716 } 663 717 }, 664 - [onSelectVideo, onImageAdd], 718 + [post.id, onSelectVideo, onImageAdd], 665 719 ) 666 720 667 721 return ( 668 - <> 722 + <View style={[styles.post, !isActive && styles.inactivePost]}> 669 723 <View 670 724 style={[ 671 725 styles.textInputLayout, ··· 685 739 setRichText={rt => { 686 740 dispatchPost({type: 'update_richtext', richtext: rt}) 687 741 }} 742 + onFocus={() => { 743 + dispatch({ 744 + type: 'focus_post', 745 + postId: post.id, 746 + }) 747 + }} 688 748 onPhotoPasted={onPhotoPasted} 689 749 onNewLink={onNewLink} 690 750 onError={onError} ··· 697 757 /> 698 758 </View> 699 759 760 + {canRemovePost && isActive && ( 761 + <> 762 + <Button 763 + label={_(msg`Delete post`)} 764 + size="small" 765 + color="secondary" 766 + variant="ghost" 767 + shape="round" 768 + style={[a.absolute, {top: 0, right: 0}]} 769 + onPress={() => { 770 + if ( 771 + post.shortenedGraphemeLength > 0 || 772 + post.embed.media || 773 + post.embed.link || 774 + post.embed.quote 775 + ) { 776 + discardPromptControl.open() 777 + } else { 778 + dispatch({ 779 + type: 'remove_post', 780 + postId: post.id, 781 + }) 782 + } 783 + }}> 784 + <ButtonIcon icon={X} /> 785 + </Button> 786 + <Prompt.Basic 787 + control={discardPromptControl} 788 + title={_(msg`Discard post?`)} 789 + description={_(msg`Are you sure you'd like to discard this post?`)} 790 + onConfirm={() => { 791 + dispatch({ 792 + type: 'remove_post', 793 + postId: post.id, 794 + }) 795 + }} 796 + confirmButtonCta={_(msg`Discard`)} 797 + confirmButtonColor="negative" 798 + /> 799 + </> 800 + )} 801 + 700 802 <ComposerEmbeds 701 803 canRemoveQuote={canRemoveQuote} 702 804 embed={post.embed} 703 805 dispatch={dispatchPost} 704 - clearVideo={onClearVideo} 806 + clearVideo={() => onClearVideo(post.id)} 807 + isActivePost={isActive} 705 808 /> 706 - </> 809 + </View> 707 810 ) 708 - } 811 + }) 709 812 710 813 function ComposerTopBar({ 711 814 canPost, 712 815 isReply, 713 816 isPublishQueued, 714 817 isPublishing, 818 + isThread, 715 819 publishingStage, 716 820 onCancel, 717 821 onPublish, ··· 723 827 canPost: boolean 724 828 isReply: boolean 725 829 isPublishQueued: boolean 830 + isThread: boolean 726 831 onCancel: () => void 727 832 onPublish: () => void 728 833 topBarAnimatedStyle: StyleProp<ViewStyle> ··· 769 874 <ButtonText style={[a.text_md]}> 770 875 {isReply ? ( 771 876 <Trans context="action">Reply</Trans> 877 + ) : isThread ? ( 878 + <Trans context="action">Post All</Trans> 772 879 ) : ( 773 880 <Trans context="action">Post</Trans> 774 881 )} ··· 781 888 ) 782 889 } 783 890 784 - function AltTextReminder() { 891 + function AltTextReminder({error}: {error: string}) { 785 892 const pal = usePalette('default') 786 893 return ( 787 894 <View style={[styles.reminderLine, pal.viewLight]}> ··· 792 899 size={10} 793 900 /> 794 901 </View> 795 - <Text style={[pal.text, a.flex_1]}> 796 - <Trans>One or more images is missing alt text.</Trans> 797 - </Text> 902 + <Text style={[pal.text, a.flex_1]}>{error}</Text> 798 903 </View> 799 904 ) 800 905 } ··· 804 909 dispatch, 805 910 clearVideo, 806 911 canRemoveQuote, 912 + isActivePost, 807 913 }: { 808 914 embed: EmbedDraft 809 915 dispatch: (action: PostAction) => void 810 916 clearVideo: () => void 811 917 canRemoveQuote: boolean 918 + isActivePost: boolean 812 919 }) { 813 920 const video = embed.media?.type === 'video' ? embed.media.video : null 814 921 return ( ··· 860 967 <VideoPreview 861 968 asset={video.asset} 862 969 video={video.video} 970 + isActivePost={isActivePost} 863 971 setDimensions={(width: number, height: number) => { 864 972 dispatch({ 865 973 type: 'embed_update_video', ··· 988 1096 function ComposerFooter({ 989 1097 post, 990 1098 dispatch, 1099 + showAddButton, 991 1100 onEmojiButtonPress, 992 1101 onError, 993 1102 onSelectVideo, 1103 + onAddPost, 994 1104 }: { 995 1105 post: PostDraft 996 1106 dispatch: (action: PostAction) => void 1107 + showAddButton: boolean 997 1108 onEmojiButtonPress: () => void 998 1109 onError: (error: string) => void 999 - onSelectVideo: (asset: ImagePickerAsset) => void 1110 + onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1111 + onAddPost: () => void 1000 1112 }) { 1001 1113 const t = useTheme() 1002 1114 const {_} = useLingui() ··· 1047 1159 onAdd={onImageAdd} 1048 1160 /> 1049 1161 <SelectVideoBtn 1050 - onSelectVideo={onSelectVideo} 1162 + onSelectVideo={asset => onSelectVideo(post.id, asset)} 1051 1163 disabled={!!media} 1052 1164 setError={onError} 1053 1165 /> ··· 1072 1184 )} 1073 1185 </View> 1074 1186 <View style={[a.flex_row, a.align_center, a.justify_between]}> 1187 + {showAddButton && ( 1188 + <Button 1189 + label={_(msg`Add new post`)} 1190 + onPress={onAddPost} 1191 + style={[a.p_sm, a.m_2xs]} 1192 + variant="ghost" 1193 + shape="round" 1194 + color="primary"> 1195 + <FontAwesomeIcon 1196 + icon="add" 1197 + size={20} 1198 + color={t.palette.primary_500} 1199 + /> 1200 + </Button> 1201 + )} 1075 1202 <SelectLangBtn /> 1076 1203 <CharProgress 1077 1204 count={post.shortenedGraphemeLength} ··· 1179 1306 }) 1180 1307 1181 1308 return { 1309 + contentHeight, 1182 1310 scrollHandler, 1183 1311 onScrollViewContentSizeChange, 1184 1312 onScrollViewLayout, ··· 1217 1345 ) 1218 1346 } 1219 1347 1348 + function isEmptyPost(post: PostDraft) { 1349 + return ( 1350 + post.richtext.text.trim().length === 0 && 1351 + !post.embed.media && 1352 + !post.embed.link && 1353 + !post.embed.quote 1354 + ) 1355 + } 1356 + 1220 1357 const styles = StyleSheet.create({ 1221 1358 topbarInner: { 1222 1359 flexDirection: 'row', ··· 1261 1398 justifyContent: 'center', 1262 1399 marginRight: 5, 1263 1400 }, 1401 + post: { 1402 + marginHorizontal: 16, 1403 + }, 1404 + inactivePost: { 1405 + opacity: 0.5, 1406 + }, 1264 1407 scrollView: { 1265 1408 flex: 1, 1266 - paddingHorizontal: 16, 1267 1409 }, 1268 1410 textInputLayout: { 1269 1411 flexDirection: 'row',
+1
src/view/com/composer/ComposerReplyTo.tsx
··· 204 204 paddingTop: 4, 205 205 paddingBottom: 16, 206 206 marginBottom: 12, 207 + marginHorizontal: 16, 207 208 }, 208 209 replyToPost: { 209 210 flex: 1,
+4 -1
src/view/com/composer/photos/Gallery.tsx
··· 159 159 } 160 160 161 161 return ( 162 - <View style={imageStyle}> 162 + <View 163 + style={imageStyle} 164 + // Fixes ALT and icons appearing with half opacity when the post is inactive 165 + renderToHardwareTextureAndroid> 163 166 <TouchableOpacity 164 167 testID="altTextButton" 165 168 accessibilityRole="button"
+3 -1
src/view/com/composer/select-language/SelectLangBtn.tsx
··· 7 7 import {msg} from '@lingui/macro' 8 8 import {useLingui} from '@lingui/react' 9 9 10 + import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' 10 11 import {usePalette} from '#/lib/hooks/usePalette' 11 12 import {isNative} from '#/platform/detection' 12 13 import {useModalControls} from '#/state/modals' ··· 102 103 items={items} 103 104 openUpwards 104 105 style={styles.button} 106 + hitSlop={LANG_DROPDOWN_HITSLOP} 105 107 accessibilityLabel={_(msg`Language selection`)} 106 108 accessibilityHint=""> 107 109 {postLanguagesPref.length > 0 ? ( ··· 121 123 122 124 const styles = StyleSheet.create({ 123 125 button: { 124 - paddingHorizontal: 15, 126 + marginHorizontal: 15, 125 127 }, 126 128 label: { 127 129 maxWidth: 100,
+80 -1
src/view/com/composer/state/composer.ts
··· 85 85 86 86 export type ComposerState = { 87 87 thread: ThreadDraft 88 - activePostIndex: number // TODO: Add actions to update this. 88 + activePostIndex: number 89 + mutableNeedsFocusActive: boolean 90 + mutableNeedsScrollToBottom: boolean 89 91 } 90 92 91 93 export type ComposerAction = ··· 95 97 type: 'update_post' 96 98 postId: string 97 99 postAction: PostAction 100 + } 101 + | { 102 + type: 'add_post' 103 + } 104 + | { 105 + type: 'remove_post' 106 + postId: string 107 + } 108 + | { 109 + type: 'focus_post' 110 + postId: string 98 111 } 99 112 100 113 export const MAX_IMAGES = 4 ··· 142 155 }, 143 156 } 144 157 } 158 + case 'add_post': { 159 + const activePostIndex = state.activePostIndex 160 + const isAtTheEnd = activePostIndex === state.thread.posts.length - 1 161 + const nextPosts = [...state.thread.posts] 162 + nextPosts.splice(activePostIndex + 1, 0, { 163 + id: nanoid(), 164 + richtext: new RichText({text: ''}), 165 + shortenedGraphemeLength: 0, 166 + labels: [], 167 + embed: { 168 + quote: undefined, 169 + media: undefined, 170 + link: undefined, 171 + }, 172 + }) 173 + return { 174 + ...state, 175 + mutableNeedsScrollToBottom: isAtTheEnd, 176 + thread: { 177 + ...state.thread, 178 + posts: nextPosts, 179 + }, 180 + } 181 + } 182 + case 'remove_post': { 183 + if (state.thread.posts.length < 2) { 184 + return state 185 + } 186 + let nextActivePostIndex = state.activePostIndex 187 + const indexToRemove = state.thread.posts.findIndex( 188 + p => p.id === action.postId, 189 + ) 190 + let nextPosts = [...state.thread.posts] 191 + if (indexToRemove !== -1) { 192 + const postToRemove = state.thread.posts[indexToRemove] 193 + if (postToRemove.embed.media?.type === 'video') { 194 + postToRemove.embed.media.video.abortController.abort() 195 + } 196 + nextPosts.splice(indexToRemove, 1) 197 + nextActivePostIndex = Math.max(0, indexToRemove - 1) 198 + } 199 + return { 200 + ...state, 201 + activePostIndex: nextActivePostIndex, 202 + mutableNeedsFocusActive: true, 203 + thread: { 204 + ...state.thread, 205 + posts: nextPosts, 206 + }, 207 + } 208 + } 209 + case 'focus_post': { 210 + const nextActivePostIndex = state.thread.posts.findIndex( 211 + p => p.id === action.postId, 212 + ) 213 + if (nextActivePostIndex === -1) { 214 + return state 215 + } 216 + return { 217 + ...state, 218 + activePostIndex: nextActivePostIndex, 219 + } 220 + } 145 221 } 146 222 } 147 223 ··· 275 351 const prevMedia = state.embed.media 276 352 let nextMedia = prevMedia 277 353 if (prevMedia?.type === 'video') { 354 + prevMedia.video.abortController.abort() 278 355 nextMedia = undefined 279 356 } 280 357 let nextLabels = state.labels ··· 436 513 }) 437 514 return { 438 515 activePostIndex: 0, 516 + mutableNeedsFocusActive: false, 517 + mutableNeedsScrollToBottom: false, 439 518 thread: { 440 519 posts: [ 441 520 {
+11 -2
src/view/com/composer/text-input/TextInput.web.tsx
··· 47 47 onPressPublish: (richtext: RichText) => void 48 48 onNewLink: (uri: string) => void 49 49 onError: (err: string) => void 50 + onFocus: () => void 50 51 } 51 52 52 53 export const TextInput = React.forwardRef(function TextInputImpl( ··· 58 59 onPhotoPasted, 59 60 onPressPublish, 60 61 onNewLink, 62 + onFocus, 61 63 }: // onError, TODO 62 64 TextInputProps, 63 65 ref, ··· 149 151 const editor = useEditor( 150 152 { 151 153 extensions, 154 + onFocus() { 155 + onFocus?.() 156 + }, 152 157 editorProps: { 153 158 attributes: { 154 159 class: modeClass, ··· 244 249 }, [onEmojiInserted]) 245 250 246 251 React.useImperativeHandle(ref, () => ({ 247 - focus: () => {}, // TODO 248 - blur: () => {}, // TODO 252 + focus: () => { 253 + editor?.chain().focus() 254 + }, 255 + blur: () => { 256 + editor?.chain().blur() 257 + }, 249 258 getCursorPosition: () => { 250 259 const pos = editor?.state.selection.$anchor.pos 251 260 return pos ? editor?.view.coordsAtPos(pos) : undefined
+15 -7
src/view/com/composer/videos/VideoPreview.tsx
··· 9 9 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' 10 10 import {atoms as a, useTheme} from '#/alf' 11 11 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 12 + import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' 12 13 13 14 export function VideoPreview({ 14 15 asset, 15 16 video, 16 17 clear, 18 + isActivePost, 17 19 }: { 18 20 asset: ImagePickerAsset 19 21 video: CompressedVideo 22 + isActivePost: boolean 20 23 setDimensions: (width: number, height: number) => void 21 24 clear: () => void 22 25 }) { ··· 42 45 t.atoms.border_contrast_low, 43 46 {backgroundColor: 'black'}, 44 47 ]}> 45 - <BlueskyVideoView 46 - url={video.uri} 47 - autoplay={!autoplayDisabled} 48 - beginMuted={true} 49 - forceTakeover={true} 50 - ref={playerRef} 51 - /> 48 + <View style={[a.absolute, a.inset_0]}> 49 + <VideoTranscodeBackdrop uri={asset.uri} /> 50 + </View> 51 + {isActivePost && ( 52 + <BlueskyVideoView 53 + url={video.uri} 54 + autoplay={!autoplayDisabled} 55 + beginMuted={true} 56 + forceTakeover={true} 57 + ref={playerRef} 58 + /> 59 + )} 52 60 <ExternalEmbedRemoveBtn onRemove={clear} /> 53 61 {autoplayDisabled && ( 54 62 <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+4 -1
src/view/com/util/forms/DropdownButton.tsx
··· 2 2 import { 3 3 Dimensions, 4 4 GestureResponderEvent, 5 + Insets, 5 6 StyleProp, 6 7 StyleSheet, 7 8 TouchableOpacity, ··· 64 65 openUpwards?: boolean 65 66 rightOffset?: number 66 67 bottomOffset?: number 68 + hitSlop?: Insets 67 69 accessibilityLabel?: string 68 70 accessibilityHint?: string 69 71 } ··· 80 82 openUpwards = false, 81 83 rightOffset = 0, 82 84 bottomOffset = 0, 85 + hitSlop = HITSLOP_10, 83 86 accessibilityLabel, 84 87 }: PropsWithChildren<DropdownButtonProps>) { 85 88 const {_} = useLingui() ··· 152 155 testID={testID} 153 156 style={style} 154 157 onPress={onPress} 155 - hitSlop={HITSLOP_10} 158 + hitSlop={hitSlop} 156 159 ref={ref1} 157 160 accessibilityRole="button" 158 161 accessibilityLabel={
+4 -4
yarn.lock
··· 4114 4114 resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" 4115 4115 integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== 4116 4116 4117 - "@haileyok/bluesky-video@0.2.3": 4118 - version "0.2.3" 4119 - resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.3.tgz#096fc65d49b4811f79ecb83c39b44409902f4f3e" 4120 - integrity sha512-1j1t/o2zrrh09LaZGzfoXQu+LuigJ1+HxQ30jq0naD2FEfQF1zLz6zOtDyywDSR8SUl9O0KsZxBi+wvQW1NgbA== 4117 + "@haileyok/bluesky-video@0.2.4": 4118 + version "0.2.4" 4119 + resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.4.tgz#1591cbf744640e0cdac2a4bcaec22df916ce34b5" 4120 + integrity sha512-Vm2rdZYPUwD8Ncxqyy+YlBVhtJFkLhutGyoZayZJ5ATV/S8msbN4RK8MgXLvzlJfW497cLbk1w6M8kPU1xoDEQ== 4121 4121 4122 4122 "@hapi/accept@^6.0.3": 4123 4123 version "6.0.3"