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

+130 -28
+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 import {createSinglePathSVG} from './TEMPLATE' 2 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', 5 })
··· 1 import {createSinglePathSVG} from './TEMPLATE' 2 3 export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ 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 })
+122 -23
src/view/com/composer/Composer.tsx
··· 9 import { 10 ActivityIndicator, 11 Keyboard, 12 StyleSheet, 13 TouchableOpacity, 14 View, ··· 19 } from 'react-native-keyboard-controller' 20 import Animated, { 21 interpolateColor, 22 useAnimatedStyle, 23 useSharedValue, 24 withTiming, ··· 169 }), 170 [insets, isKeyboardVisible], 171 ) 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 189 const onPressCancel = useCallback(() => { 190 if (graphemeLength > 0 || !gallery.isEmpty) { ··· 395 [setExtLink], 396 ) 397 398 return ( 399 <> 400 <KeyboardAvoidingView 401 testID="composePostView" 402 behavior="padding" 403 style={a.flex_1} 404 - keyboardVerticalOffset={replyTo ? 120 : isAndroid ? 180 : 150}> 405 <View 406 style={[a.flex_1, viewStyles]} 407 aria-modal ··· 509 <Animated.ScrollView 510 onScroll={scrollHandler} 511 style={styles.scrollView} 512 - keyboardShouldPersistTaps="always"> 513 {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 514 515 <View ··· 575 <KeyboardStickyView 576 offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}> 577 {replyTo ? null : ( 578 - <ThreadgateBtn threadgate={threadgate} onChange={setThreadgate} /> 579 )} 580 <View 581 style={[ ··· 625 return useRef<CancelRef>(null) 626 } 627 628 const styles = StyleSheet.create({ 629 - topbar: { 630 - borderBottomWidth: StyleSheet.hairlineWidth, 631 - }, 632 topbarDesktop: { 633 paddingTop: 10, 634 paddingBottom: 10, ··· 698 bottomBar: { 699 flexDirection: 'row', 700 paddingVertical: 4, 701 - paddingLeft: 8, 702 paddingRight: 16, 703 alignItems: 'center', 704 borderTopWidth: hairlineWidth,
··· 9 import { 10 ActivityIndicator, 11 Keyboard, 12 + LayoutChangeEvent, 13 StyleSheet, 14 TouchableOpacity, 15 View, ··· 20 } from 'react-native-keyboard-controller' 21 import Animated, { 22 interpolateColor, 23 + runOnUI, 24 useAnimatedStyle, 25 useSharedValue, 26 withTiming, ··· 171 }), 172 [insets, isKeyboardVisible], 173 ) 174 175 const onPressCancel = useCallback(() => { 176 if (graphemeLength > 0 || !gallery.isEmpty) { ··· 381 [setExtLink], 382 ) 383 384 + const { 385 + scrollHandler, 386 + onScrollViewContentSizeChange, 387 + onScrollViewLayout, 388 + topBarAnimatedStyle, 389 + bottomBarAnimatedStyle, 390 + } = useAnimatedBorders() 391 + 392 return ( 393 <> 394 <KeyboardAvoidingView 395 testID="composePostView" 396 behavior="padding" 397 style={a.flex_1} 398 + keyboardVerticalOffset={replyTo ? 110 : isAndroid ? 180 : 140}> 399 <View 400 style={[a.flex_1, viewStyles]} 401 aria-modal ··· 503 <Animated.ScrollView 504 onScroll={scrollHandler} 505 style={styles.scrollView} 506 + keyboardShouldPersistTaps="always" 507 + onContentSizeChange={onScrollViewContentSizeChange} 508 + onLayout={onScrollViewLayout}> 509 {replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined} 510 511 <View ··· 571 <KeyboardStickyView 572 offset={{closed: isIOS ? -insets.bottom : 0, opened: 0}}> 573 {replyTo ? null : ( 574 + <ThreadgateBtn 575 + threadgate={threadgate} 576 + onChange={setThreadgate} 577 + style={bottomBarAnimatedStyle} 578 + /> 579 )} 580 <View 581 style={[ ··· 625 return useRef<CancelRef>(null) 626 } 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 + 728 const styles = StyleSheet.create({ 729 + topbar: {}, 730 topbarDesktop: { 731 paddingTop: 10, 732 paddingBottom: 10, ··· 796 bottomBar: { 797 flexDirection: 'row', 798 paddingVertical: 4, 799 + // should be 8 but due to visual alignment we have to fudge it 800 + paddingLeft: 7, 801 paddingRight: 16, 802 alignItems: 'center', 803 borderTopWidth: hairlineWidth,
+6 -3
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 import React from 'react' 2 - import {Keyboard, View} from 'react-native' 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' 5 ··· 16 export function ThreadgateBtn({ 17 threadgate, 18 onChange, 19 }: { 20 threadgate: ThreadgateSetting[] 21 onChange: (v: ThreadgateSetting[]) => void 22 }) { 23 const {track} = useAnalytics() 24 const {_} = useLingui() ··· 46 : _(msg`Some people can reply`) 47 48 return ( 49 - <View style={[a.flex_row, a.py_xs, a.px_sm, t.atoms.bg]}> 50 <Button 51 variant="solid" 52 color="secondary" ··· 59 /> 60 <ButtonText>{label}</ButtonText> 61 </Button> 62 - </View> 63 ) 64 }
··· 1 import React from 'react' 2 + import {Keyboard, StyleProp, ViewStyle} from 'react-native' 3 + import Animated, {AnimatedStyle} from 'react-native-reanimated' 4 import {msg} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 ··· 17 export function ThreadgateBtn({ 18 threadgate, 19 onChange, 20 + style, 21 }: { 22 threadgate: ThreadgateSetting[] 23 onChange: (v: ThreadgateSetting[]) => void 24 + style?: StyleProp<AnimatedStyle<ViewStyle>> 25 }) { 26 const {track} = useAnalytics() 27 const {_} = useLingui() ··· 49 : _(msg`Some people can reply`) 50 51 return ( 52 + <Animated.View style={[a.flex_row, a.p_sm, t.atoms.bg, style]}> 53 <Button 54 variant="solid" 55 color="secondary" ··· 62 /> 63 <ButtonText>{label}</ButtonText> 64 </Button> 65 + </Animated.View> 66 ) 67 }