Bluesky app fork with some witchin' additions 💫

Composer - add animated bottom border (#4325)

* start adding bottom border (wip)

* add content change listener

* add layout listener and move to hook

* remove logs

* use square-er image icon

* visually align bottom bar icons

* reduce keyboard vertical offset slightly

* only add border to top/bottom

* run worklet function on UI thread

authored by samuel.fm and committed by GitHub 891b432e 3b55f61d

Changed files
+130 -28
assets
src
components
icons
view
com
composer
+1 -1
assets/icons/image_stroke2_corner0_rounded.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg> 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z" clip-rule="evenodd"/></svg>
+1 -1
src/components/icons/Image.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 3 export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', 4 + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', 5 5 })
+122 -23
src/view/com/composer/Composer.tsx
··· 9 9 import { 10 10 ActivityIndicator, 11 11 Keyboard, 12 + LayoutChangeEvent, 12 13 StyleSheet, 13 14 TouchableOpacity, 14 15 View, ··· 19 20 } from 'react-native-keyboard-controller' 20 21 import Animated, { 21 22 interpolateColor, 23 + runOnUI, 22 24 useAnimatedStyle, 23 25 useSharedValue, 24 26 withTiming, ··· 169 171 }), 170 172 [insets, isKeyboardVisible], 171 173 ) 172 - 173 - const hasScrolled = useSharedValue(0) 174 - const scrollHandler = useAnimatedScrollHandler({ 175 - onScroll: event => { 176 - hasScrolled.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) 177 - }, 178 - }) 179 - const topBarAnimatedStyle = useAnimatedStyle(() => { 180 - return { 181 - borderColor: interpolateColor( 182 - hasScrolled.value, 183 - [0, 1], 184 - ['transparent', t.atoms.border_contrast_medium.borderColor], 185 - ), 186 - } 187 - }) 188 174 189 175 const onPressCancel = useCallback(() => { 190 176 if (graphemeLength > 0 || !gallery.isEmpty) { ··· 395 381 [setExtLink], 396 382 ) 397 383 384 + const { 385 + scrollHandler, 386 + onScrollViewContentSizeChange, 387 + onScrollViewLayout, 388 + topBarAnimatedStyle, 389 + bottomBarAnimatedStyle, 390 + } = useAnimatedBorders() 391 + 398 392 return ( 399 393 <> 400 394 <KeyboardAvoidingView 401 395 testID="composePostView" 402 396 behavior="padding" 403 397 style={a.flex_1} 404 - keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}> 398 + keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}> 405 399 <View 406 400 style={[a.flex_1, viewStyles]} 407 401 aria-modal ··· 509 503 <Animated.ScrollView 510 504 onScroll={scrollHandler} 511 505 style={styles.scrollView} 512 - keyboardShouldPersistTaps="always"> 506 + keyboardShouldPersistTaps="always" 507 + onContentSizeChange={onScrollViewContentSizeChange} 508 + onLayout={onScrollViewLayout}> 513 509 {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 514 510 515 511 <View ··· 575 571 <KeyboardStickyView 576 572 offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}> 577 573 {replyTo ? null : ( 578 - <ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} /> 574 + <ThreadgateBtn 575 + threadgate={threadgate} 576 + onChange={setThreadgate} 577 + style={bottomBarAnimatedStyle} 578 + /> 579 579 )} 580 580 <View 581 581 style={[ ··· 625 625 return useRef<CancelRef>(null) 626 626 } 627 627 628 + function useAnimatedBorders() { 629 + const t = useTheme() 630 + const hasScrolledTop = useSharedValue(0) 631 + const hasScrolledBottom = useSharedValue(0) 632 + const contentOffset = useSharedValue(0) 633 + const scrollViewHeight = useSharedValue(Infinity) 634 + const contentHeight = useSharedValue(0) 635 + 636 + /** 637 + * Make sure to run this on the UI thread! 638 + */ 639 + const showHideBottomBorder = useCallback( 640 + ({ 641 + newContentHeight, 642 + newContentOffset, 643 + newScrollViewHeight, 644 + }: { 645 + newContentHeight?: number 646 + newContentOffset?: number 647 + newScrollViewHeight?: number 648 + }) => { 649 + 'worklet' 650 + 651 + if (typeof newContentHeight === 'number') 652 + contentHeight.value = newContentHeight 653 + if (typeof newContentOffset === 'number') 654 + contentOffset.value = newContentOffset 655 + if (typeof newScrollViewHeight === 'number') 656 + scrollViewHeight.value = newScrollViewHeight 657 + 658 + hasScrolledBottom.value = withTiming( 659 + contentHeight.value - contentOffset.value >= scrollViewHeight.value 660 + ? 1 661 + : 0, 662 + ) 663 + }, 664 + [contentHeight, contentOffset, scrollViewHeight, hasScrolledBottom], 665 + ) 666 + 667 + const scrollHandler = useAnimatedScrollHandler({ 668 + onScroll: event => { 669 + hasScrolledTop.value = withTiming(event.contentOffset.y > 0 ? 1 : 0) 670 + 671 + // already on UI thread 672 + showHideBottomBorder({ 673 + newContentOffset: event.contentOffset.y, 674 + newContentHeight: event.contentSize.height, 675 + newScrollViewHeight: event.layoutMeasurement.height, 676 + }) 677 + }, 678 + }) 679 + 680 + const onScrollViewContentSizeChange = useCallback( 681 + (_width: number, height: number) => { 682 + runOnUI(showHideBottomBorder)({ 683 + newContentHeight: height, 684 + }) 685 + }, 686 + [showHideBottomBorder], 687 + ) 688 + 689 + const onScrollViewLayout = useCallback( 690 + (evt: LayoutChangeEvent) => { 691 + runOnUI(showHideBottomBorder)({ 692 + newScrollViewHeight: evt.nativeEvent.layout.height, 693 + }) 694 + }, 695 + [showHideBottomBorder], 696 + ) 697 + 698 + const topBarAnimatedStyle = useAnimatedStyle(() => { 699 + return { 700 + borderBottomWidth: hairlineWidth, 701 + borderColor: interpolateColor( 702 + hasScrolledTop.value, 703 + [0, 1], 704 + ['transparent', t.atoms.border_contrast_medium.borderColor], 705 + ), 706 + } 707 + }) 708 + const bottomBarAnimatedStyle = useAnimatedStyle(() => { 709 + return { 710 + borderTopWidth: hairlineWidth, 711 + borderColor: interpolateColor( 712 + hasScrolledBottom.value, 713 + [0, 1], 714 + ['transparent', t.atoms.border_contrast_medium.borderColor], 715 + ), 716 + } 717 + }) 718 + 719 + return { 720 + scrollHandler, 721 + onScrollViewContentSizeChange, 722 + onScrollViewLayout, 723 + topBarAnimatedStyle, 724 + bottomBarAnimatedStyle, 725 + } 726 + } 727 + 628 728 const styles = StyleSheet.create({ 629 - topbar: { 630 - borderBottomWidth: StyleSheet.hairlineWidth, 631 - }, 729 + topbar: {}, 632 730 topbarDesktop: { 633 731 paddingTop: 10, 634 732 paddingBottom: 10, ··· 698 796 bottomBar: { 699 797 flexDirection: 'row', 700 798 paddingVertical: 4, 701 - paddingLeft: 8, 799 + // should be 8 but due to visual alignment we have to fudge it 800 + paddingLeft: 7, 702 801 paddingRight: 16, 703 802 alignItems: 'center', 704 803 borderTopWidth: hairlineWidth,
+6 -3
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 1 import React from 'react' 2 - import {Keyboard, View} from 'react-native' 2 + import {Keyboard, StyleProp, ViewStyle} from 'react-native' 3 + import Animated, {AnimatedStyle} from 'react-native-reanimated' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 6 ··· 16 17 export function ThreadgateBtn({ 17 18 threadgate, 18 19 onChange, 20 + style, 19 21 }: { 20 22 threadgate: ThreadgateSetting[] 21 23 onChange: (v: ThreadgateSetting[]) => void 24 + style?: StyleProp<AnimatedStyle<ViewStyle>> 22 25 }) { 23 26 const {track} = useAnalytics() 24 27 const {_} = useLingui() ··· 46 49 : _(msg`Some people can reply`) 47 50 48 51 return ( 49 - <View style={[a.flex_row, a.py_xs, a.px_sm, t.atoms.bg]}> 52 + <Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}> 50 53 <Button 51 54 variant="solid" 52 55 color="secondary" ··· 59 62 /> 60 63 <ButtonText>{label}</ButtonText> 61 64 </Button> 62 - </View> 65 + </Animated.View> 63 66 ) 64 67 }