Bluesky app fork with some witchin' additions 💫

Merge branch 'main' of https://github.com/bluesky-social/social-app

+1
.eslintrc.js
··· 43 43 suggestedTextWrappers: { 44 44 Button: 'ButtonText', 45 45 'ToggleButton.Button': 'ToggleButton.ButtonText', 46 + 'SegmentedControl.Item': 'SegmentedControl.ItemText', 46 47 }, 47 48 }, 48 49 ],
+1
assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.928 18.882a1.95 1.95 0 0 0 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z"/></svg>
+6 -1
modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
··· 243 243 val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 244 244 bottomSheet?.let { 245 245 val behavior = BottomSheetBehavior.from(it) 246 + val currentState = behavior.state 246 247 247 - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 248 + val oldRatio = behavior.halfExpandedRatio 249 + var newRatio = getHalfExpandedRatio(contentHeight) 250 + behavior.halfExpandedRatio = newRatio 248 251 249 252 if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { 250 253 behavior.state = BottomSheetBehavior.STATE_EXPANDED 251 254 } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { 255 + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 256 + } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { 252 257 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 253 258 } 254 259 }
+2 -2
modules/bottom-sheet/index.ts
··· 1 1 import {BottomSheet} from './src/BottomSheet' 2 2 import { 3 3 BottomSheetSnapPoint, 4 - BottomSheetState, 5 - BottomSheetViewProps, 4 + type BottomSheetState, 5 + type BottomSheetViewProps, 6 6 } from './src/BottomSheet.types' 7 7 import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' 8 8 import {
+15 -3
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 112 112 onStateChange={this.onStateChange} 113 113 extraStyles={extraStyles} 114 114 onLayout={e => { 115 - const {height} = e.nativeEvent.layout 116 - this.setState({viewHeight: height}) 117 - this.updateLayout() 115 + if (isIOS15) { 116 + const {height} = e.nativeEvent.layout 117 + this.setState({viewHeight: height}) 118 + } 119 + if (Platform.OS === 'android') { 120 + // TEMP HACKFIX: I had to timebox this, but this is Bad. 121 + // On Android, if you run updateLayout() immediately, 122 + // it will take ages to actually run on the native side. 123 + // However, adding literally any delay will fix this, including 124 + // a console.log() - just sending the log to the CLI is enough. 125 + // TODO: Get to the bottom of this and fix it properly! -sfn 126 + setTimeout(() => this.updateLayout()) 127 + } else { 128 + this.updateLayout() 129 + } 118 130 }} 119 131 /> 120 132 </Portal>
+1 -1
package.json
··· 143 143 "expo-font": "~14.0.9", 144 144 "expo-haptics": "~15.0.7", 145 145 "expo-image": "~3.0.10", 146 - "expo-image-crop-tool": "^0.1.8", 146 + "expo-image-crop-tool": "^0.4.0", 147 147 "expo-image-manipulator": "~14.0.7", 148 148 "expo-image-picker": "~17.0.8", 149 149 "expo-intent-launcher": "~13.0.7",
+2 -1
src/alf/typography.tsx
··· 25 25 fontFamily: Alf['fonts']['family'] 26 26 } & Pick<Alf, 'flags'>, 27 27 ) { 28 - const s = flatten(styles) 28 + const s = flatten(styles) ?? {} 29 + 29 30 // should always be defined on these components 30 31 s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale 31 32
+1
src/components/AppLanguageDropdown.tsx
··· 48 48 })} 49 49 variant="ghost" 50 50 color="secondary" 51 + shape="rectangular" 51 52 style={[ 52 53 a.pr_xs, 53 54 a.pl_sm,
+94 -46
src/components/Button.tsx
··· 39 39 | 'primary_subtle' 40 40 | 'negative_subtle' 41 41 export type ButtonSize = 'tiny' | 'small' | 'large' 42 - export type ButtonShape = 'round' | 'square' | 'default' 42 + export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default' 43 43 export type VariantProps = { 44 44 /** 45 45 * The style variation of the button ··· 56 56 size?: ButtonSize 57 57 /** 58 58 * The shape of the button 59 + * 60 + * - `default`: Pill shaped. Most buttons should use this shape. 61 + * - `round`: Circular. For icon-only buttons. 62 + * - `square`: Square. For icon-only buttons. 63 + * - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields. 59 64 */ 60 65 shape?: ButtonShape 61 66 } ··· 437 442 if (size === 'large') { 438 443 baseStyles.push(a.rounded_full, { 439 444 paddingVertical: 12, 445 + paddingHorizontal: 24, 446 + gap: 6, 447 + }) 448 + } else if (size === 'small') { 449 + baseStyles.push(a.rounded_full, { 450 + paddingVertical: 8, 451 + paddingHorizontal: 14, 452 + gap: 5, 453 + }) 454 + } else if (size === 'tiny') { 455 + baseStyles.push(a.rounded_full, { 456 + paddingVertical: 5, 457 + paddingHorizontal: 10, 458 + gap: 3, 459 + }) 460 + } 461 + } else if (shape === 'rectangular') { 462 + if (size === 'large') { 463 + baseStyles.push({ 464 + paddingVertical: 12, 440 465 paddingHorizontal: 25, 466 + borderRadius: 10, 441 467 gap: 3, 442 468 }) 443 469 } else if (size === 'small') { 444 - baseStyles.push(a.rounded_full, { 470 + baseStyles.push({ 445 471 paddingVertical: 8, 446 472 paddingHorizontal: 13, 473 + borderRadius: 8, 447 474 gap: 3, 448 475 }) 449 476 } else if (size === 'tiny') { 450 - baseStyles.push(a.rounded_full, { 477 + baseStyles.push({ 451 478 paddingVertical: 5, 452 479 paddingHorizontal: 9, 480 + borderRadius: 6, 453 481 gap: 2, 454 482 }) 455 483 } ··· 503 531 variant, 504 532 color, 505 533 size, 534 + shape, 506 535 disabled: disabled || false, 507 536 }), 508 - [state, variant, color, size, disabled], 537 + [state, variant, color, size, shape, disabled], 509 538 ) 510 539 511 540 return ( ··· 746 775 position?: 'left' | 'right' 747 776 size?: SVGIconProps['size'] 748 777 }) { 749 - const {size: buttonSize} = useButtonContext() 778 + const {size: buttonSize, shape: buttonShape} = useButtonContext() 750 779 const textStyles = useSharedButtonTextStyles() 751 - const {iconSize, iconContainerSize} = React.useMemo(() => { 752 - /** 753 - * Pre-set icon sizes for different button sizes 754 - */ 755 - const iconSizeShorthand = 756 - size ?? 757 - (({ 758 - large: 'md', 759 - small: 'sm', 760 - tiny: 'xs', 761 - }[buttonSize || 'small'] || 'sm') as Exclude< 762 - SVGIconProps['size'], 763 - undefined 764 - >) 780 + const {iconSize, iconContainerSize, iconNegativeMargin} = 781 + React.useMemo(() => { 782 + /** 783 + * Pre-set icon sizes for different button sizes 784 + */ 785 + const iconSizeShorthand = 786 + size ?? 787 + (({ 788 + large: 'md', 789 + small: 'sm', 790 + tiny: 'xs', 791 + }[buttonSize || 'small'] || 'sm') as Exclude< 792 + SVGIconProps['size'], 793 + undefined 794 + >) 765 795 766 - /* 767 - * Copied here from icons/common.tsx so we can tweak if we need to, but 768 - * also so that we can calculate transforms. 769 - */ 770 - const iconSize = { 771 - xs: 12, 772 - sm: 16, 773 - md: 18, 774 - lg: 24, 775 - xl: 28, 776 - '2xl': 32, 777 - }[iconSizeShorthand] 796 + /* 797 + * Copied here from icons/common.tsx so we can tweak if we need to, but 798 + * also so that we can calculate transforms. 799 + */ 800 + const iconSize = { 801 + '2xs': 8, 802 + xs: 12, 803 + sm: 16, 804 + md: 18, 805 + lg: 24, 806 + xl: 28, 807 + '2xl': 32, 808 + }[iconSizeShorthand] 778 809 779 - /* 780 - * Goal here is to match rendered text size so that different size icons 781 - * don't increase button size 782 - */ 783 - const iconContainerSize = { 784 - large: 20, 785 - small: 17, 786 - tiny: 15, 787 - }[buttonSize || 'small'] 810 + /* 811 + * Goal here is to match rendered text size so that different size icons 812 + * don't increase button size 813 + */ 814 + const iconContainerSize = { 815 + large: 20, 816 + small: 17, 817 + tiny: 15, 818 + }[buttonSize || 'small'] 788 819 789 - return { 790 - iconSize, 791 - iconContainerSize, 792 - } 793 - }, [buttonSize, size]) 820 + /* 821 + * The icon needs to be closer to the edge of the button than the text. Therefore 822 + * we make the gap slightly too large, and then pull in the sides using negative margins. 823 + */ 824 + let iconNegativeMargin = 0 825 + 826 + if (buttonShape === 'default') { 827 + iconNegativeMargin = { 828 + large: -2, 829 + small: -2, 830 + tiny: -1, 831 + }[buttonSize || 'small'] 832 + } 833 + 834 + return { 835 + iconSize, 836 + iconContainerSize, 837 + iconNegativeMargin, 838 + } 839 + }, [buttonSize, buttonShape, size]) 794 840 795 841 return ( 796 842 <View 797 843 style={[ 798 844 a.z_20, 799 845 { 800 - width: iconContainerSize, 846 + width: size === '2xs' ? 10 : iconContainerSize, 801 847 height: iconContainerSize, 848 + marginLeft: iconNegativeMargin, 849 + marginRight: iconNegativeMargin, 802 850 }, 803 851 ]}> 804 852 <View
+1 -1
src/components/Pills.tsx
··· 170 170 }, [size]) 171 171 172 172 return ( 173 - <View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}> 173 + <View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}> 174 174 <Text style={[a.text_xs, a.leading_tight]}> 175 175 <Trans>Follows You</Trans> 176 176 </Text>
-2
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 7 7 8 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 9 import {atoms as a} from '#/alf' 10 - import {MediaInsetBorder} from '#/components/MediaInsetBorder' 11 10 import * as BandwidthEstimate from './bandwidth-estimate' 12 11 import {Controls} from './web-controls/VideoControls' 13 12 ··· 102 101 hasSubtitleTrack={hasSubtitleTrack} 103 102 /> 104 103 </div> 105 - <MediaInsetBorder /> 106 104 </View> 107 105 ) 108 106 }
+9 -29
src/components/Post/Embed/VideoEmbed/index.tsx
··· 7 7 8 8 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 9 9 import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' 10 - import {atoms as a, useTheme} from '#/alf' 10 + import {atoms as a} from '#/alf' 11 11 import {Button} from '#/components/Button' 12 12 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 13 13 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' ··· 16 16 17 17 interface Props { 18 18 embed: AppBskyEmbedVideo.View 19 - crop?: 'none' | 'square' | 'constrained' 20 19 } 21 20 22 - export function VideoEmbed({embed, crop}: Props) { 23 - const t = useTheme() 21 + export function VideoEmbed({embed}: Props) { 24 22 const [key, setKey] = useState(0) 25 23 26 24 const renderError = useCallback( ··· 40 38 } 41 39 42 40 let constrained: number | undefined 43 - let max: number | undefined 44 41 if (aspectRatio !== undefined) { 45 42 const ratio = 1 / 2 // max of 1:2 ratio in feeds 46 43 constrained = Math.max(aspectRatio, ratio) 47 - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 48 44 } 49 - const cropDisabled = crop === 'none' 50 45 51 46 const contents = ( 52 47 <ErrorBoundary renderError={renderError} key={key}> ··· 56 51 57 52 return ( 58 53 <View style={[a.pt_xs]}> 59 - {cropDisabled ? ( 60 - <View 61 - style={[ 62 - a.w_full, 63 - a.overflow_hidden, 64 - {aspectRatio: max ?? 1}, 65 - a.rounded_md, 66 - a.overflow_hidden, 67 - t.atoms.bg_contrast_25, 68 - ]}> 69 - {contents} 70 - </View> 71 - ) : ( 72 - <ConstrainedImage 73 - fullBleed={crop === 'square'} 74 - aspectRatio={constrained || 1} 75 - // slightly smaller max height than images 76 - // images use 16 / 9, for reference 77 - minMobileAspectRatio={14 / 9}> 78 - {contents} 79 - </ConstrainedImage> 80 - )} 54 + <ConstrainedImage 55 + aspectRatio={constrained || 1} 56 + // slightly smaller max height than images 57 + // images use 16 / 9, for reference 58 + minMobileAspectRatio={14 / 9}> 59 + {contents} 60 + </ConstrainedImage> 81 61 </View> 82 62 ) 83 63 }
+15 -33
src/components/Post/Embed/VideoEmbed/index.web.tsx
··· 17 17 import {atoms as a, useTheme} from '#/alf' 18 18 import {useIsWithinMessage} from '#/components/dms/MessageContext' 19 19 import {useFullscreen} from '#/components/hooks/useFullscreen' 20 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 20 21 import { 21 22 HLSUnsupportedError, 22 23 VideoEmbedInnerWeb, ··· 25 26 import {useActiveVideoWeb} from './ActiveVideoWebContext' 26 27 import * as VideoFallback from './VideoEmbedInner/VideoFallback' 27 28 28 - export function VideoEmbed({ 29 - embed, 30 - crop, 31 - }: { 32 - embed: AppBskyEmbedVideo.View 33 - crop?: 'none' | 'square' | 'constrained' 34 - }) { 29 + export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { 35 30 const t = useTheme() 36 31 const ref = useRef<HTMLDivElement>(null) 37 32 const {active, setActive, sendPosition, currentActiveView} = ··· 76 71 } 77 72 78 73 let constrained: number | undefined 79 - let max: number | undefined 80 74 if (aspectRatio !== undefined) { 81 75 const ratio = 1 / 2 // max of 1:2 ratio in feeds 82 76 constrained = Math.max(aspectRatio, ratio) 83 - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 84 77 } 85 - const cropDisabled = crop === 'none' 86 78 87 79 const contents = ( 88 80 <div ··· 91 83 display: 'flex', 92 84 flex: 1, 93 85 cursor: 'default', 86 + backgroundColor: t.palette.black, 94 87 backgroundImage: `url(${embed.thumbnail})`, 95 - backgroundSize: 'cover', 88 + backgroundSize: 'contain', 89 + backgroundPosition: 'center', 90 + backgroundRepeat: 'no-repeat', 96 91 }} 97 92 onClick={evt => evt.stopPropagation()}> 98 93 <ErrorBoundary renderError={renderError} key={key}> ··· 114 109 <ViewportObserver 115 110 sendPosition={sendPosition} 116 111 isAnyViewActive={currentActiveView !== null}> 117 - {cropDisabled ? ( 118 - <View 119 - style={[ 120 - a.w_full, 121 - a.overflow_hidden, 122 - {aspectRatio: max ?? 1}, 123 - a.rounded_md, 124 - a.overflow_hidden, 125 - t.atoms.bg_contrast_25, 126 - ]}> 127 - {contents} 128 - </View> 129 - ) : ( 130 - <ConstrainedImage 131 - fullBleed={crop === 'square'} 132 - aspectRatio={constrained || 1} 133 - // slightly smaller max height than images 134 - // images use 16 / 9, for reference 135 - minMobileAspectRatio={14 / 9}> 136 - {contents} 137 - </ConstrainedImage> 138 - )} 112 + <ConstrainedImage 113 + fullBleed 114 + aspectRatio={constrained || 1} 115 + // slightly smaller max height than images 116 + // images use 16 / 9, for reference 117 + minMobileAspectRatio={14 / 9}> 118 + {contents} 119 + <MediaInsetBorder /> 120 + </ConstrainedImage> 139 121 </ViewportObserver> 140 122 </View> 141 123 )
+1 -1
src/components/Post/Embed/index.tsx
··· 115 115 <ContentHider 116 116 modui={rest.moderation?.ui('contentMedia')} 117 117 activeStyle={[a.mt_sm]}> 118 - <VideoEmbed embed={embed.view} crop="constrained" /> 118 + <VideoEmbed embed={embed.view} /> 119 119 </ContentHider> 120 120 ) 121 121 }
+3 -3
src/components/RichText.tsx
··· 1 1 import React from 'react' 2 - import {type TextStyle} from 'react-native' 2 + import {type StyleProp, type TextStyle} from 'react-native' 3 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 4 5 5 import {toShortUrl} from '#/lib/strings/url-helpers' ··· 21 21 enableTags?: boolean 22 22 authorHandle?: string 23 23 onLinkPress?: LinkProps['onPress'] 24 - interactiveStyle?: TextStyle 24 + interactiveStyle?: StyleProp<TextStyle> 25 25 emojiMultiplier?: number 26 26 shouldProxyLinks?: boolean 27 27 } ··· 55 55 56 56 if (!facets?.length) { 57 57 if (isOnlyEmoji(text)) { 58 - const flattenedStyle = flatten(style) 58 + const flattenedStyle = flatten(style) ?? {} 59 59 const fontSize = 60 60 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier 61 61 return (
+1 -1
src/components/Select/index.tsx
··· 106 106 style={[a.flex_1, a.justify_between]} 107 107 color="secondary" 108 108 size="small" 109 - variant="solid"> 109 + shape="rectangular"> 110 110 <>{children}</> 111 111 </Button> 112 112 )
+18 -8
src/components/Select/index.web.tsx
··· 1 - import {createContext, forwardRef, useContext, useMemo} from 'react' 1 + import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react' 2 2 import {View} from 'react-native' 3 3 import {Select as RadixSelect} from 'radix-ui' 4 4 ··· 96 96 style={flatten([ 97 97 a.flex, 98 98 a.relative, 99 - t.atoms.bg_contrast_25, 100 - a.rounded_sm, 99 + t.atoms.bg_contrast_50, 101 100 a.w_full, 102 101 a.align_center, 103 102 a.gap_sm, ··· 106 105 a.px_md, 107 106 a.pointer, 108 107 { 108 + borderRadius: 10, 109 109 maxWidth: 400, 110 110 outline: 0, 111 111 borderWidth: 2, 112 112 borderStyle: 'solid', 113 113 borderColor: focused 114 114 ? t.palette.primary_500 115 - : hovered 116 - ? t.palette.contrast_100 117 - : t.palette.contrast_25, 115 + : t.palette.contrast_50, 118 116 }, 119 117 ])}> 120 118 {children} ··· 140 138 ) 141 139 } 142 140 143 - export function Content<T>({items, renderItem}: ContentProps<T>) { 141 + export function Content<T>({ 142 + items, 143 + renderItem, 144 + valueExtractor = defaultItemValueExtractor, 145 + }: ContentProps<T>) { 144 146 const t = useTheme() 145 147 const selectedValue = useContext(SelectedValueContext) 146 148 ··· 198 200 <ChevronUpIcon style={[t.atoms.text]} size="xs" /> 199 201 </RadixSelect.ScrollUpButton> 200 202 <RadixSelect.Viewport style={flatten([a.p_xs])}> 201 - {items.map((item, index) => renderItem(item, index, selectedValue))} 203 + {items.map((item, index) => ( 204 + <Fragment key={valueExtractor(item)}> 205 + {renderItem(item, index, selectedValue)} 206 + </Fragment> 207 + ))} 202 208 </RadixSelect.Viewport> 203 209 <RadixSelect.ScrollDownButton style={flatten(down)}> 204 210 <ChevronDownIcon style={[t.atoms.text]} size="xs" /> ··· 207 213 </RadixSelect.Content> 208 214 </RadixSelect.Portal> 209 215 ) 216 + } 217 + 218 + function defaultItemValueExtractor(item: any) { 219 + return item.value 210 220 } 211 221 212 222 const ItemContext = createContext<{
+65 -9
src/components/Tooltip/index.tsx
··· 12 12 import Animated, {Easing, ZoomIn} from 'react-native-reanimated' 13 13 import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 14 15 + import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 16 + import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 15 17 import {atoms as a, select, useTheme} from '#/alf' 16 18 import {useOnGesture} from '#/components/hooks/useOnGesture' 17 - import {Portal} from '#/components/Portal' 19 + import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' 18 20 import { 19 21 ARROW_HALF_SIZE, 20 22 ARROW_SIZE, ··· 22 24 MIN_EDGE_SPACE, 23 25 } from '#/components/Tooltip/const' 24 26 import {Text} from '#/components/Typography' 27 + 28 + const TooltipPortal = createPortalGroup() 29 + const TooltipProviderContext = 30 + createContext<React.RefObject<View | null> | null>(null) 31 + 32 + /** 33 + * Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container, 34 + * such as in the composer sheet. 35 + * 36 + * Only really necessary on iOS but can work on Android. 37 + */ 38 + export function SheetCompatProvider({children}: {children: React.ReactNode}) { 39 + const ref = useRef<View | null>(null) 40 + return ( 41 + <GlobalGestureEventsProvider style={[a.flex_1]}> 42 + <TooltipPortal.Provider> 43 + <View ref={ref} collapsable={false} style={[a.flex_1]}> 44 + <TooltipProviderContext value={ref}> 45 + {children} 46 + </TooltipProviderContext> 47 + </View> 48 + <TooltipPortal.Outlet /> 49 + </TooltipPortal.Provider> 50 + </GlobalGestureEventsProvider> 51 + ) 52 + } 53 + SheetCompatProvider.displayName = 'TooltipSheetCompatProvider' 25 54 26 55 /** 27 56 * These are native specific values, not shared with web ··· 120 149 121 150 export function Target({children}: {children: React.ReactNode}) { 122 151 const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) 152 + const [hasLayedOut, setHasLayedOut] = useState(false) 123 153 const targetRef = useRef<View>(null) 154 + const containerRef = useContext(TooltipProviderContext) 155 + const keyboardIsOpen = useIsKeyboardVisible() 124 156 125 157 useEffect(() => { 126 - if (!shouldMeasure) return 158 + if (!shouldMeasure || !hasLayedOut) return 127 159 /* 128 160 * Once opened, measure the dimensions and position of the target 129 161 */ 130 - targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { 131 - if (pageX !== undefined && pageY !== undefined && width && height) { 132 - setTargetMeasurements({x: pageX, y: pageY, width, height}) 133 - } 134 - }) 135 - }, [shouldMeasure, setTargetMeasurements]) 162 + 163 + if (containerRef?.current) { 164 + targetRef.current?.measureLayout( 165 + containerRef.current, 166 + (x, y, width, height) => { 167 + if (x !== undefined && y !== undefined && width && height) { 168 + setTargetMeasurements({x, y, width, height}) 169 + } 170 + }, 171 + ) 172 + } else { 173 + targetRef.current?.measure((_x, _y, width, height, x, y) => { 174 + if (x !== undefined && y !== undefined && width && height) { 175 + setTargetMeasurements({x, y, width, height}) 176 + } 177 + }) 178 + } 179 + }, [ 180 + shouldMeasure, 181 + setTargetMeasurements, 182 + hasLayedOut, 183 + containerRef, 184 + keyboardIsOpen, 185 + ]) 136 186 137 187 return ( 138 - <View collapsable={false} ref={targetRef}> 188 + <View 189 + collapsable={false} 190 + ref={targetRef} 191 + onLayout={() => setHasLayedOut(true)}> 139 192 {children} 140 193 </View> 141 194 ) ··· 150 203 }) { 151 204 const {position, visible, onVisibleChange} = useContext(TooltipContext) 152 205 const {targetMeasurements} = useContext(TargetContext) 206 + const isWithinProvider = !!useContext(TooltipProviderContext) 153 207 const requestClose = useCallback(() => { 154 208 onVisibleChange(false) 155 209 }, [onVisibleChange]) 156 210 157 211 if (!visible || !targetMeasurements) return null 212 + 213 + const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal 158 214 159 215 return ( 160 216 <Portal>
+14 -8
src/components/Tooltip/index.web.tsx
··· 11 11 } from '#/components/Tooltip/const' 12 12 import {Text} from '#/components/Typography' 13 13 14 + // Portal Provider on native, but we actually don't need to do anything here 15 + export function Provider({children}: {children: React.ReactNode}) { 16 + return <>{children}</> 17 + } 18 + Provider.displayName = 'TooltipProvider' 19 + 14 20 type TooltipContextType = { 15 21 position: 'top' | 'bottom' 16 22 onVisibleChange: (open: boolean) => void 17 23 } 18 24 19 - const TooltipContext = createContext<TooltipContextType>({ 25 + const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({ 20 26 position: 'bottom', 21 - onVisibleChange: () => {}, 22 27 }) 23 28 TooltipContext.displayName = 'TooltipContext' 24 29 ··· 33 38 visible: boolean 34 39 onVisibleChange: (visible: boolean) => void 35 40 }) { 36 - const ctx = useMemo( 37 - () => ({position, onVisibleChange}), 38 - [position, onVisibleChange], 39 - ) 41 + const ctx = useMemo(() => ({position}), [position]) 40 42 return ( 41 43 <Popover.Root open={visible} onOpenChange={onVisibleChange}> 42 44 <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> ··· 60 62 label: string 61 63 }) { 62 64 const t = useTheme() 63 - const {position, onVisibleChange} = useContext(TooltipContext) 65 + const {position} = useContext(TooltipContext) 64 66 return ( 65 67 <Popover.Portal> 66 68 <Popover.Content ··· 69 71 side={position} 70 72 sideOffset={4} 71 73 collisionPadding={MIN_EDGE_SPACE} 72 - onInteractOutside={() => onVisibleChange(false)} 74 + onInteractOutside={evt => { 75 + if (evt.type === 'dismissableLayer.focusOutside') { 76 + evt.preventDefault() 77 + } 78 + }} 73 79 style={flatten([ 74 80 a.rounded_sm, 75 81 select(t.name, {
+43 -16
src/components/WhoCanReply.tsx
··· 1 - import {Fragment, useMemo} from 'react' 1 + import {Fragment, useMemo, useRef} from 'react' 2 2 import { 3 3 Keyboard, 4 4 Platform, ··· 22 22 type ThreadgateAllowUISetting, 23 23 threadgateViewToAllowUISetting, 24 24 } from '#/state/queries/threadgate' 25 - import {atoms as a, useTheme, web} from '#/alf' 25 + import {atoms as a, native, useTheme, web} from '#/alf' 26 26 import {Button, ButtonText} from '#/components/Button' 27 27 import * as Dialog from '#/components/Dialog' 28 28 import {useDialogControl} from '#/components/Dialog' ··· 30 30 PostInteractionSettingsDialog, 31 31 usePrefetchPostInteractionSettings, 32 32 } from '#/components/dialogs/PostInteractionSettingsDialog' 33 - import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 34 - import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 35 - import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 33 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron' 34 + import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign' 35 + import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 36 + import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 36 37 import {InlineLinkText} from '#/components/Link' 37 38 import {Text} from '#/components/Typography' 38 39 import * as bsky from '#/types/bsky' 39 - import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 40 40 41 41 interface WhoCanReplyProps { 42 42 post: AppBskyFeedDefs.PostView ··· 69 69 postUri: post.uri, 70 70 rootPostUri: rootUri, 71 71 }) 72 + const prefetchPromise = useRef<Promise<void>>(Promise.resolve()) 73 + 74 + const prefetch = () => { 75 + prefetchPromise.current = prefetchPostInteractionSettings() 76 + } 72 77 73 78 const anyoneCanReply = 74 79 settings.length === 1 && settings[0].type === 'everybody' ··· 84 89 Keyboard.dismiss() 85 90 } 86 91 if (isThreadAuthor) { 87 - editDialogControl.open() 92 + // wait on prefetch if it manages to resolve in under 200ms 93 + // otherwise, proceed immediately and show the spinner -sfn 94 + Promise.race([ 95 + prefetchPromise.current, 96 + new Promise(res => setTimeout(res, 200)), 97 + ]).finally(() => { 98 + editDialogControl.open() 99 + }) 88 100 } else { 89 101 infoDialogControl.open() 90 102 } ··· 100 112 {...(isThreadAuthor 101 113 ? Platform.select({ 102 114 web: { 103 - onHoverIn: prefetchPostInteractionSettings, 115 + onHoverIn: prefetch, 104 116 }, 105 117 native: { 106 - onPressIn: prefetchPostInteractionSettings, 118 + onPressIn: prefetch, 107 119 }, 108 120 }) 109 121 : {})} 110 122 hitSlop={HITSLOP_10}> 111 - {({hovered}) => ( 112 - <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> 123 + {({hovered, focused, pressed}) => ( 124 + <View 125 + style={[ 126 + a.flex_row, 127 + a.align_center, 128 + a.gap_xs, 129 + (hovered || focused || pressed) && native({opacity: 0.5}), 130 + style, 131 + ]}> 113 132 <Icon 114 - color={t.palette.contrast_400} 133 + color={ 134 + isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400 135 + } 115 136 width={16} 116 137 settings={settings} 117 138 /> ··· 119 140 style={[ 120 141 a.text_sm, 121 142 a.leading_tight, 122 - t.atoms.text_contrast_medium, 123 - hovered && a.underline, 143 + isThreadAuthor 144 + ? {color: t.palette.primary_500} 145 + : t.atoms.text_contrast_medium, 146 + (hovered || focused || pressed) && web(a.underline), 124 147 ]}> 125 148 {description} 126 149 </Text> 127 150 128 151 {isThreadAuthor && ( 129 - <PencilLine width={12} fill={t.palette.primary_500} /> 152 + <TinyChevronDownIcon width={8} fill={t.palette.primary_500} /> 130 153 )} 131 154 </View> 132 155 )} ··· 164 187 settings.length === 0 || 165 188 settings.every(setting => setting.type === 'everybody') 166 189 const isNobody = !!settings.find(gate => gate.type === 'nobody') 167 - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group 190 + const IconComponent = isEverybody 191 + ? EarthIcon 192 + : isNobody 193 + ? CircleBanSignIcon 194 + : GroupIcon 168 195 return <IconComponent fill={color} width={width} /> 169 196 } 170 197
+16 -4
src/components/activity-notifications/SubscribeProfileButton.tsx
··· 1 - import {useCallback} from 'react' 1 + import {useCallback, useEffect, useState} from 'react' 2 2 import {type ModerationOpts} from '@atproto/api' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' ··· 27 27 const subscribeDialogControl = useDialogControl() 28 28 const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = 29 29 useActivitySubscriptionsNudged() 30 + const [showTooltip, setShowTooltip] = useState(false) 30 31 31 - const onDismissTooltip = () => { 32 + useEffect(() => { 33 + if (!activitySubscriptionsNudged) { 34 + const timeout = setTimeout(() => { 35 + setShowTooltip(true) 36 + }, 500) 37 + return () => clearTimeout(timeout) 38 + } 39 + }, [activitySubscriptionsNudged]) 40 + 41 + const onDismissTooltip = (visible: boolean) => { 42 + if (visible) return 43 + 44 + setShowTooltip(false) 32 45 setActivitySubscriptionsNudged(true) 33 46 } 34 47 ··· 56 69 return ( 57 70 <> 58 71 <Tooltip.Outer 59 - visible={!activitySubscriptionsNudged} 72 + visible={showTooltip} 60 73 onVisibleChange={onDismissTooltip} 61 74 position="bottom"> 62 75 <Tooltip.Target> ··· 65 78 testID="dmBtn" 66 79 size="small" 67 80 color="secondary" 68 - variant="solid" 69 81 shape="round" 70 82 label={_(msg`Get notified when ${name} posts`)} 71 83 onPress={wrappedOnPress}>
+18 -17
src/components/dialogs/Embed.tsx
··· 10 10 import {atoms as a, useTheme} from '#/alf' 11 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 12 import * as Dialog from '#/components/Dialog' 13 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 13 14 import * as TextField from '#/components/forms/TextField' 14 - import * as ToggleButton from '#/components/forms/ToggleButton' 15 15 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 16 16 import { 17 17 ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, ··· 150 150 <Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}> 151 151 <Trans>Color theme</Trans> 152 152 </Text> 153 - <ToggleButton.Group 153 + <SegmentedControl.Root 154 154 label={_(msg`Color mode`)} 155 - values={[colorMode]} 156 - onChange={([value]) => setColorMode(value as ColorModeValues)}> 157 - <ToggleButton.Button name="system" label={_(msg`System`)}> 158 - <ToggleButton.ButtonText> 155 + type="radio" 156 + value={colorMode} 157 + onChange={setColorMode}> 158 + <SegmentedControl.Item value="system" label={_(msg`System`)}> 159 + <SegmentedControl.ItemText> 159 160 <Trans>System</Trans> 160 - </ToggleButton.ButtonText> 161 - </ToggleButton.Button> 162 - <ToggleButton.Button name="light" label={_(msg`Light`)}> 163 - <ToggleButton.ButtonText> 161 + </SegmentedControl.ItemText> 162 + </SegmentedControl.Item> 163 + <SegmentedControl.Item value="light" label={_(msg`Light`)}> 164 + <SegmentedControl.ItemText> 164 165 <Trans>Light</Trans> 165 - </ToggleButton.ButtonText> 166 - </ToggleButton.Button> 167 - <ToggleButton.Button name="dark" label={_(msg`Dark`)}> 168 - <ToggleButton.ButtonText> 166 + </SegmentedControl.ItemText> 167 + </SegmentedControl.Item> 168 + <SegmentedControl.Item value="dark" label={_(msg`Dark`)}> 169 + <SegmentedControl.ItemText> 169 170 <Trans>Dark</Trans> 170 - </ToggleButton.ButtonText> 171 - </ToggleButton.Button> 172 - </ToggleButton.Group> 171 + </SegmentedControl.ItemText> 172 + </SegmentedControl.Item> 173 + </SegmentedControl.Root> 173 174 </View> 174 175 )} 175 176 </View>
+393 -280
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 1 - import React from 'react' 2 - import {type StyleProp, View, type ViewStyle} from 'react-native' 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {LayoutAnimation, Text as NestedText, View} from 'react-native' 3 3 import { 4 4 type AppBskyFeedDefs, 5 5 type AppBskyFeedPostgate, 6 6 AtUri, 7 7 } from '@atproto/api' 8 - import {msg, Trans} from '@lingui/macro' 8 + import {msg, Plural, Trans} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 10 import {useQueryClient} from '@tanstack/react-query' 11 - import isEqual from 'lodash.isequal' 12 11 12 + import {useHaptics} from '#/lib/haptics' 13 13 import {logger} from '#/logger' 14 + import {isIOS} from '#/platform/detection' 14 15 import {STALE} from '#/state/queries' 15 16 import {useMyListsQuery} from '#/state/queries/my-lists' 16 17 import {useGetPost} from '#/state/queries/post' ··· 37 38 } from '#/state/queries/usePostThread' 38 39 import {useAgent, useSession} from '#/state/session' 39 40 import * as Toast from '#/view/com/util/Toast' 40 - import {atoms as a, useTheme} from '#/alf' 41 + import {UserAvatar} from '#/view/com/util/UserAvatar' 42 + import {atoms as a, useTheme, web} from '#/alf' 41 43 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 42 44 import * as Dialog from '#/components/Dialog' 43 - import {Divider} from '#/components/Divider' 44 45 import * as Toggle from '#/components/forms/Toggle' 45 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 46 + import { 47 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 48 + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 49 + } from '#/components/icons/Chevron' 46 50 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 51 + import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 47 52 import {Loader} from '#/components/Loader' 48 53 import {Text} from '#/components/Typography' 49 54 ··· 52 57 onSave: () => void 53 58 isSaving?: boolean 54 59 60 + isDirty?: boolean 61 + persist?: boolean 62 + onChangePersist?: (v: boolean) => void 63 + 55 64 postgate: AppBskyFeedPostgate.Record 56 65 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 57 66 ··· 61 70 replySettingsDisabled?: boolean 62 71 } 63 72 73 + /** 74 + * Threadgate settings dialog. Used in the composer. 75 + */ 64 76 export function PostInteractionSettingsControlledDialog({ 65 77 control, 66 78 ...rest 67 79 }: PostInteractionSettingsFormProps & { 68 80 control: Dialog.DialogControlProps 69 81 }) { 70 - const t = useTheme() 71 - const {_} = useLingui() 72 - 73 82 return ( 74 - <Dialog.Outer control={control}> 83 + <Dialog.Outer 84 + control={control} 85 + nativeOptions={{ 86 + preventExpansion: true, 87 + preventDismiss: rest.isDirty && rest.persist, 88 + }}> 75 89 <Dialog.Handle /> 76 - <Dialog.ScrollableInner 77 - label={_(msg`Edit post interaction settings`)} 78 - style={[{maxWidth: 500}, a.w_full]}> 79 - <View style={[a.gap_md]}> 80 - <Header /> 81 - <PostInteractionSettingsForm {...rest} /> 82 - <Text 83 - style={[ 84 - a.pt_sm, 85 - a.text_sm, 86 - a.leading_snug, 87 - t.atoms.text_contrast_medium, 88 - ]}> 89 - <Trans> 90 - You can set default interaction settings in{' '} 91 - <Text style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 92 - Settings &rarr; Moderation &rarr; Interaction settings 93 - </Text> 94 - . 95 - </Trans> 96 - </Text> 97 - </View> 98 - <Dialog.Close /> 99 - </Dialog.ScrollableInner> 90 + <DialogInner {...rest} /> 100 91 </Dialog.Outer> 101 92 ) 102 93 } 103 94 104 - export function Header() { 95 + function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) { 96 + const {_} = useLingui() 97 + 105 98 return ( 106 - <View style={[a.gap_md, a.pb_sm]}> 107 - <Text style={[a.text_2xl, a.font_semi_bold]}> 108 - <Trans>Post interaction settings</Trans> 109 - </Text> 110 - <Text style={[a.text_md, a.pb_xs]}> 111 - <Trans>Customize who can interact with this post.</Trans> 112 - </Text> 113 - <Divider /> 114 - </View> 99 + <Dialog.ScrollableInner 100 + label={_(msg`Edit post interaction settings`)} 101 + style={[web({maxWidth: 400}), a.w_full]}> 102 + <Header /> 103 + <PostInteractionSettingsForm {...props} /> 104 + <Dialog.Close /> 105 + </Dialog.ScrollableInner> 115 106 ) 116 107 } 117 108 ··· 134 125 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 135 126 } 136 127 128 + /** 129 + * Threadgate settings dialog. Used in the thread. 130 + */ 137 131 export function PostInteractionSettingsDialog( 138 132 props: PostInteractionSettingsDialogProps, 139 133 ) { 140 134 const postThreadContext = usePostThreadContext() 141 135 return ( 142 - <Dialog.Outer control={props.control}> 136 + <Dialog.Outer 137 + control={props.control} 138 + nativeOptions={{preventExpansion: true}}> 143 139 <Dialog.Handle /> 144 140 <PostThreadContextProvider context={postThreadContext}> 145 141 <PostInteractionSettingsDialogControlledInner {...props} /> ··· 153 149 ) { 154 150 const {_} = useLingui() 155 151 const {currentAccount} = useSession() 156 - const [isSaving, setIsSaving] = React.useState(false) 152 + const [isSaving, setIsSaving] = useState(false) 157 153 158 154 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 159 155 useThreadgateViewQuery({postUri: props.rootPostUri}) ··· 165 161 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 166 162 167 163 const [editedPostgate, setEditedPostgate] = 168 - React.useState<AppBskyFeedPostgate.Record>() 164 + useState<AppBskyFeedPostgate.Record>() 169 165 const [editedAllowUISettings, setEditedAllowUISettings] = 170 - React.useState<ThreadgateAllowUISetting[]>() 166 + useState<ThreadgateAllowUISetting[]>() 171 167 172 168 const isLoading = isLoadingThreadgate || isLoadingPostgate 173 169 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 174 - const isThreadgateOwnedByViewer = React.useMemo(() => { 170 + const isThreadgateOwnedByViewer = useMemo(() => { 175 171 return currentAccount?.did === new AtUri(props.rootPostUri).host 176 172 }, [props.rootPostUri, currentAccount?.did]) 177 173 178 - const postgateValue = React.useMemo(() => { 174 + const postgateValue = useMemo(() => { 179 175 return ( 180 176 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 181 177 ) 182 178 }, [postgate, editedPostgate, props.postUri]) 183 - const allowUIValue = React.useMemo(() => { 179 + const allowUIValue = useMemo(() => { 184 180 return ( 185 181 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 186 182 ) 187 183 }, [threadgateView, editedAllowUISettings]) 188 184 189 - const onSave = React.useCallback(async () => { 185 + const onSave = useCallback(async () => { 190 186 if (!editedPostgate && !editedAllowUISettings) { 191 187 props.control.close() 192 188 return ··· 248 244 return ( 249 245 <Dialog.ScrollableInner 250 246 label={_(msg`Edit post interaction settings`)} 251 - style={[{maxWidth: 500}, a.w_full]}> 252 - <View style={[a.gap_md]}> 253 - <Header /> 254 - 255 - {isLoading ? ( 256 - <View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}> 257 - <Loader size="xl" /> 258 - </View> 259 - ) : ( 247 + style={[web({maxWidth: 400}), a.w_full]}> 248 + {isLoading ? ( 249 + <View 250 + style={[ 251 + a.flex_1, 252 + a.py_5xl, 253 + a.gap_md, 254 + a.align_center, 255 + a.justify_center, 256 + ]}> 257 + <Loader size="xl" /> 258 + <Text style={[a.italic, a.text_center]}> 259 + <Trans>Loading post interaction settings...</Trans> 260 + </Text> 261 + </View> 262 + ) : ( 263 + <> 264 + <Header /> 260 265 <PostInteractionSettingsForm 261 266 replySettingsDisabled={!isThreadgateOwnedByViewer} 262 267 isSaving={isSaving} ··· 266 271 threadgateAllowUISettings={allowUIValue} 267 272 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 268 273 /> 269 - )} 270 - </View> 274 + </> 275 + )} 276 + <Dialog.Close /> 271 277 </Dialog.ScrollableInner> 272 278 ) 273 279 } ··· 281 287 threadgateAllowUISettings, 282 288 onChangeThreadgateAllowUISettings, 283 289 replySettingsDisabled, 290 + isDirty, 291 + persist, 292 + onChangePersist, 284 293 }: PostInteractionSettingsFormProps) { 285 294 const t = useTheme() 286 295 const {_} = useLingui() 287 - const {data: lists} = useMyListsQuery('curate') 288 - const [quotesEnabled, setQuotesEnabled] = React.useState( 296 + const playHaptic = useHaptics() 297 + const [showLists, setShowLists] = useState(false) 298 + const { 299 + data: lists, 300 + isPending: isListsPending, 301 + isError: isListsError, 302 + } = useMyListsQuery('curate') 303 + const [quotesEnabled, setQuotesEnabled] = useState( 289 304 !( 290 305 postgate.embeddingRules && 291 306 postgate.embeddingRules.find( ··· 294 309 ), 295 310 ) 296 311 297 - const onPressAudience = (setting: ThreadgateAllowUISetting) => { 298 - // remove boolean values 299 - let newSelected: ThreadgateAllowUISetting[] = 300 - threadgateAllowUISettings.filter( 301 - v => v.type !== 'nobody' && v.type !== 'everybody', 302 - ) 303 - // toggle 304 - const i = newSelected.findIndex(v => isEqual(v, setting)) 305 - if (i === -1) { 306 - newSelected.push(setting) 307 - } else { 308 - newSelected.splice(i, 1) 309 - } 310 - if (newSelected.length === 0) { 311 - newSelected.push({type: 'everybody'}) 312 - } 313 - 314 - onChangeThreadgateAllowUISettings(newSelected) 315 - } 316 - 317 - const onChangeQuotesEnabled = React.useCallback( 312 + const onChangeQuotesEnabled = useCallback( 318 313 (enabled: boolean) => { 319 314 setQuotesEnabled(enabled) 320 315 onChangePostgate( ··· 330 325 const noOneCanReply = !!threadgateAllowUISettings.find( 331 326 v => v.type === 'nobody', 332 327 ) 328 + const everyoneCanReply = !!threadgateAllowUISettings.find( 329 + v => v.type === 'everybody', 330 + ) 331 + const numberOfListsSelected = threadgateAllowUISettings.filter( 332 + v => v.type === 'list', 333 + ).length 333 334 334 - return ( 335 - <View> 336 - <View style={[a.flex_1, a.gap_md]}> 337 - <View style={[a.gap_lg]}> 338 - <View style={[a.gap_sm]}> 339 - <Text style={[a.font_semi_bold, a.text_lg]}> 340 - <Trans>Quote settings</Trans> 341 - </Text> 335 + const toggleGroupValues = useMemo(() => { 336 + const values: string[] = [] 337 + for (const setting of threadgateAllowUISettings) { 338 + switch (setting.type) { 339 + case 'everybody': 340 + case 'nobody': 341 + // no granularity, early return with nothing 342 + return [] 343 + case 'followers': 344 + values.push('followers') 345 + break 346 + case 'following': 347 + values.push('following') 348 + break 349 + case 'mention': 350 + values.push('mention') 351 + break 352 + case 'list': 353 + values.push(`list:${setting.list}`) 354 + break 355 + default: 356 + break 357 + } 358 + } 359 + return values 360 + }, [threadgateAllowUISettings]) 342 361 343 - <Toggle.Item 344 - name="quoteposts" 345 - type="checkbox" 346 - label={ 347 - quotesEnabled 348 - ? _(msg`Click to disable quote posts of this post.`) 349 - : _(msg`Click to enable quote posts of this post.`) 350 - } 351 - value={quotesEnabled} 352 - onChange={onChangeQuotesEnabled} 353 - style={[a.justify_between, a.pt_xs]}> 354 - <Text style={[t.atoms.text_contrast_medium]}> 355 - <Trans>Allow quote posts</Trans> 356 - </Text> 357 - <Toggle.Switch /> 358 - </Toggle.Item> 359 - </View> 362 + const toggleGroupOnChange = (values: string[]) => { 363 + const settings: ThreadgateAllowUISetting[] = [] 360 364 361 - <Divider /> 365 + if (values.length === 0) { 366 + settings.push({type: 'everybody'}) 367 + } else { 368 + for (const value of values) { 369 + if (value.startsWith('list:')) { 370 + const listId = value.slice('list:'.length) 371 + settings.push({type: 'list', list: listId}) 372 + } else { 373 + settings.push({type: value as 'followers' | 'following' | 'mention'}) 374 + } 375 + } 376 + } 362 377 363 - {replySettingsDisabled && ( 364 - <View 365 - style={[ 366 - a.px_md, 367 - a.py_sm, 368 - a.rounded_sm, 369 - a.flex_row, 370 - a.align_center, 371 - a.gap_sm, 372 - t.atoms.bg_contrast_25, 373 - ]}> 374 - <CircleInfo fill={t.atoms.text_contrast_low.color} /> 375 - <Text 376 - style={[ 377 - a.flex_1, 378 - a.leading_snug, 379 - t.atoms.text_contrast_medium, 380 - ]}> 381 - <Trans> 382 - Reply settings are chosen by the author of the thread 383 - </Trans> 384 - </Text> 385 - </View> 386 - )} 378 + onChangeThreadgateAllowUISettings(settings) 379 + } 387 380 381 + return ( 382 + <View style={[a.flex_1, a.gap_lg]}> 383 + <View style={[a.gap_lg]}> 384 + {replySettingsDisabled && ( 388 385 <View 389 386 style={[ 387 + a.px_md, 388 + a.py_sm, 389 + a.rounded_sm, 390 + a.flex_row, 391 + a.align_center, 390 392 a.gap_sm, 391 - { 392 - opacity: replySettingsDisabled ? 0.3 : 1, 393 - }, 393 + t.atoms.bg_contrast_25, 394 394 ]}> 395 - <Text style={[a.font_semi_bold, a.text_lg]}> 396 - <Trans>Reply settings</Trans> 395 + <CircleInfo fill={t.atoms.text_contrast_low.color} /> 396 + <Text 397 + style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}> 398 + <Trans> 399 + Reply settings are chosen by the author of the thread 400 + </Trans> 397 401 </Text> 402 + </View> 403 + )} 398 404 399 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 400 - <Trans>Allow replies from:</Trans> 401 - </Text> 405 + <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}> 406 + <Text style={[a.text_md, a.font_medium]}> 407 + <Trans>Who can reply</Trans> 408 + </Text> 402 409 410 + <Toggle.Group 411 + label={_(msg`Set who can reply to your post`)} 412 + type="radio" 413 + maxSelections={1} 414 + disabled={replySettingsDisabled} 415 + values={ 416 + everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : [] 417 + } 418 + onChange={val => { 419 + if (val.includes('everyone')) { 420 + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 421 + } else if (val.includes('nobody')) { 422 + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 423 + } else { 424 + onChangeThreadgateAllowUISettings([{type: 'mention'}]) 425 + } 426 + }}> 403 427 <View style={[a.flex_row, a.gap_sm]}> 404 - <Selectable 405 - label={_(msg`Everybody`)} 406 - isSelected={ 407 - !!threadgateAllowUISettings.find(v => v.type === 'everybody') 408 - } 409 - onPress={() => 410 - onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 411 - } 412 - style={{flex: 1}} 413 - disabled={replySettingsDisabled} 414 - /> 415 - <Selectable 416 - label={_(msg`Nobody`)} 417 - isSelected={noOneCanReply} 418 - onPress={() => 419 - onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 420 - } 421 - style={{flex: 1}} 422 - disabled={replySettingsDisabled} 423 - /> 428 + <Toggle.Item 429 + name="everyone" 430 + type="checkbox" 431 + label={_(msg`Allow anyone to reply`)} 432 + style={[a.flex_1]}> 433 + {({selected}) => ( 434 + <Toggle.Panel active={selected}> 435 + <Toggle.Radio /> 436 + <Toggle.PanelText> 437 + <Trans>Anyone</Trans> 438 + </Toggle.PanelText> 439 + </Toggle.Panel> 440 + )} 441 + </Toggle.Item> 442 + <Toggle.Item 443 + name="nobody" 444 + type="checkbox" 445 + label={_(msg`Disable replies entirely`)} 446 + style={[a.flex_1]}> 447 + {({selected}) => ( 448 + <Toggle.Panel active={selected}> 449 + <Toggle.Radio /> 450 + <Toggle.PanelText> 451 + <Trans>Nobody</Trans> 452 + </Toggle.PanelText> 453 + </Toggle.Panel> 454 + )} 455 + </Toggle.Item> 424 456 </View> 457 + </Toggle.Group> 425 458 426 - {!noOneCanReply && ( 427 - <> 428 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 429 - <Trans>Or combine these options:</Trans> 430 - </Text> 459 + <Toggle.Group 460 + label={_( 461 + msg`Set precisely which groups of people can reply to your post`, 462 + )} 463 + values={toggleGroupValues} 464 + onChange={toggleGroupOnChange} 465 + disabled={replySettingsDisabled}> 466 + <Toggle.PanelGroup> 467 + <Toggle.Item 468 + name="followers" 469 + type="checkbox" 470 + label={_(msg`Allow your followers to reply`)} 471 + hitSlop={0}> 472 + {({selected}) => ( 473 + <Toggle.Panel active={selected} adjacent="trailing"> 474 + <Toggle.Checkbox /> 475 + <Toggle.PanelText> 476 + <Trans>Your followers</Trans> 477 + </Toggle.PanelText> 478 + </Toggle.Panel> 479 + )} 480 + </Toggle.Item> 481 + <Toggle.Item 482 + name="following" 483 + type="checkbox" 484 + label={_(msg`Allow people you follow to reply`)} 485 + hitSlop={0}> 486 + {({selected}) => ( 487 + <Toggle.Panel active={selected} adjacent="both"> 488 + <Toggle.Checkbox /> 489 + <Toggle.PanelText> 490 + <Trans>People you follow</Trans> 491 + </Toggle.PanelText> 492 + </Toggle.Panel> 493 + )} 494 + </Toggle.Item> 495 + <Toggle.Item 496 + name="mention" 497 + type="checkbox" 498 + label={_(msg`Allow people you mention to reply`)} 499 + hitSlop={0}> 500 + {({selected}) => ( 501 + <Toggle.Panel active={selected} adjacent="both"> 502 + <Toggle.Checkbox /> 503 + <Toggle.PanelText> 504 + <Trans>People you mention</Trans> 505 + </Toggle.PanelText> 506 + </Toggle.Panel> 507 + )} 508 + </Toggle.Item> 431 509 432 - <View style={[a.gap_sm]}> 433 - <Selectable 434 - label={_(msg`Mentioned users`)} 435 - isSelected={ 436 - !!threadgateAllowUISettings.find( 437 - v => v.type === 'mention', 438 - ) 439 - } 440 - onPress={() => onPressAudience({type: 'mention'})} 441 - disabled={replySettingsDisabled} 442 - /> 443 - <Selectable 444 - label={_(msg`Users you follow`)} 445 - isSelected={ 446 - !!threadgateAllowUISettings.find( 447 - v => v.type === 'following', 448 - ) 449 - } 450 - onPress={() => onPressAudience({type: 'following'})} 451 - disabled={replySettingsDisabled} 452 - /> 453 - <Selectable 454 - label={_(msg`Your followers`)} 455 - isSelected={ 456 - !!threadgateAllowUISettings.find( 457 - v => v.type === 'followers', 458 - ) 459 - } 460 - onPress={() => onPressAudience({type: 'followers'})} 461 - disabled={replySettingsDisabled} 510 + <Button 511 + label={ 512 + showLists 513 + ? _(msg`Hide lists`) 514 + : _(msg`Show lists of users to select from`) 515 + } 516 + accessibilityHint={_(msg`Toggle showing lists`)} 517 + accessibilityRole="togglebutton" 518 + hitSlop={0} 519 + onPress={() => { 520 + playHaptic('Light') 521 + if (isIOS && !showLists) { 522 + LayoutAnimation.configureNext({ 523 + ...LayoutAnimation.Presets.linear, 524 + duration: 175, 525 + }) 526 + } 527 + setShowLists(s => !s) 528 + }}> 529 + <Toggle.Panel 530 + active={numberOfListsSelected > 0} 531 + adjacent={showLists ? 'both' : 'leading'}> 532 + <Toggle.PanelText> 533 + {numberOfListsSelected === 0 ? ( 534 + <Trans>Select from your lists</Trans> 535 + ) : ( 536 + <Trans> 537 + Select from your lists{' '} 538 + <NestedText style={[a.font_normal, a.italic]}> 539 + <Plural 540 + value={numberOfListsSelected} 541 + other="(# selected)" 542 + /> 543 + </NestedText> 544 + </Trans> 545 + )} 546 + </Toggle.PanelText> 547 + <Toggle.PanelIcon 548 + icon={showLists ? ChevronUpIcon : ChevronDownIcon} 462 549 /> 463 - {lists && lists.length > 0 464 - ? lists.map(list => ( 465 - <Selectable 466 - key={list.uri} 467 - label={_(msg`Users in "${list.name}"`)} 468 - isSelected={ 469 - !!threadgateAllowUISettings.find( 470 - v => v.type === 'list' && v.list === list.uri, 471 - ) 472 - } 473 - onPress={() => 474 - onPressAudience({type: 'list', list: list.uri}) 475 - } 476 - disabled={replySettingsDisabled} 477 - /> 478 - )) 479 - : // No loading states to avoid jumps for the common case (no lists) 480 - null} 481 - </View> 482 - </> 483 - )} 484 - </View> 550 + </Toggle.Panel> 551 + </Button> 552 + {showLists && 553 + (isListsPending ? ( 554 + <Toggle.Panel> 555 + <Toggle.PanelText> 556 + <Trans>Loading lists...</Trans> 557 + </Toggle.PanelText> 558 + </Toggle.Panel> 559 + ) : isListsError ? ( 560 + <Toggle.Panel> 561 + <Toggle.PanelText> 562 + <Trans> 563 + An error occurred while loading your lists :/ 564 + </Trans> 565 + </Toggle.PanelText> 566 + </Toggle.Panel> 567 + ) : lists.length === 0 ? ( 568 + <Toggle.Panel> 569 + <Toggle.PanelText> 570 + <Trans>You don't have any lists yet.</Trans> 571 + </Toggle.PanelText> 572 + </Toggle.Panel> 573 + ) : ( 574 + lists.map((list, i) => ( 575 + <Toggle.Item 576 + key={list.uri} 577 + name={`list:${list.uri}`} 578 + type="checkbox" 579 + label={_(msg`Allow users in ${list.name} to reply`)} 580 + hitSlop={0}> 581 + {({selected}) => ( 582 + <Toggle.Panel 583 + active={selected} 584 + adjacent={ 585 + i === lists.length - 1 ? 'leading' : 'both' 586 + }> 587 + <Toggle.Checkbox /> 588 + <UserAvatar 589 + size={24} 590 + type="list" 591 + avatar={list.avatar} 592 + /> 593 + <Toggle.PanelText>{list.name}</Toggle.PanelText> 594 + </Toggle.Panel> 595 + )} 596 + </Toggle.Item> 597 + )) 598 + ))} 599 + </Toggle.PanelGroup> 600 + </Toggle.Group> 485 601 </View> 486 602 </View> 487 603 604 + <Toggle.Item 605 + name="quoteposts" 606 + type="checkbox" 607 + label={ 608 + quotesEnabled 609 + ? _(msg`Disable quote posts of this post.`) 610 + : _(msg`Enable quote posts of this post.`) 611 + } 612 + value={quotesEnabled} 613 + onChange={onChangeQuotesEnabled}> 614 + {({selected}) => ( 615 + <Toggle.Panel active={selected}> 616 + <Toggle.PanelText icon={QuoteIcon}> 617 + <Trans>Allow quote posts</Trans> 618 + </Toggle.PanelText> 619 + <Toggle.Switch /> 620 + </Toggle.Panel> 621 + )} 622 + </Toggle.Item> 623 + 624 + {typeof persist !== 'undefined' && ( 625 + <View style={[{minHeight: 24}, a.justify_center]}> 626 + {isDirty ? ( 627 + <Toggle.Item 628 + name="persist" 629 + type="checkbox" 630 + label={_(msg`Save these options for next time`)} 631 + value={persist} 632 + onChange={() => onChangePersist?.(!persist)}> 633 + <Toggle.Checkbox /> 634 + <Toggle.LabelText 635 + style={[a.text_md, a.font_normal, t.atoms.text]}> 636 + <Trans>Save these options for next time</Trans> 637 + </Toggle.LabelText> 638 + </Toggle.Item> 639 + ) : ( 640 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 641 + <Trans>These are your default settings</Trans> 642 + </Text> 643 + )} 644 + </View> 645 + )} 646 + 488 647 <Button 489 648 disabled={!canSave || isSaving} 490 649 label={_(msg`Save`)} 491 650 onPress={onSave} 492 651 color="primary" 493 - size="large" 494 - variant="solid" 495 - style={a.mt_xl}> 496 - <ButtonText>{_(msg`Save`)}</ButtonText> 497 - {isSaving && <ButtonIcon icon={Loader} position="right" />} 652 + size="large"> 653 + <ButtonText> 654 + <Trans>Save</Trans> 655 + </ButtonText> 656 + {isSaving && <ButtonIcon icon={Loader} />} 498 657 </Button> 499 658 </View> 500 659 ) 501 660 } 502 661 503 - function Selectable({ 504 - label, 505 - isSelected, 506 - onPress, 507 - style, 508 - disabled, 509 - }: { 510 - label: string 511 - isSelected: boolean 512 - onPress: () => void 513 - style?: StyleProp<ViewStyle> 514 - disabled?: boolean 515 - }) { 516 - const t = useTheme() 662 + function Header() { 517 663 return ( 518 - <Button 519 - disabled={disabled} 520 - onPress={onPress} 521 - label={label} 522 - accessibilityRole="checkbox" 523 - aria-checked={isSelected} 524 - accessibilityState={{ 525 - checked: isSelected, 526 - }} 527 - style={a.flex_1}> 528 - {({hovered, focused}) => ( 529 - <View 530 - style={[ 531 - a.flex_1, 532 - a.flex_row, 533 - a.align_center, 534 - a.justify_between, 535 - a.rounded_sm, 536 - a.p_md, 537 - {minHeight: 40}, // for consistency with checkmark icon visible or not 538 - t.atoms.bg_contrast_50, 539 - (hovered || focused) && t.atoms.bg_contrast_100, 540 - isSelected && { 541 - backgroundColor: t.palette.primary_100, 542 - }, 543 - style, 544 - ]}> 545 - <Text style={[a.text_sm, isSelected && a.font_semi_bold]}> 546 - {label} 547 - </Text> 548 - {isSelected ? ( 549 - <Check size="sm" fill={t.palette.primary_500} /> 550 - ) : ( 551 - <View /> 552 - )} 553 - </View> 554 - )} 555 - </Button> 664 + <View style={[a.pb_lg]}> 665 + <Text style={[a.text_2xl, a.font_bold]}> 666 + <Trans>Post interaction settings</Trans> 667 + </Text> 668 + </View> 556 669 ) 557 670 } 558 671 ··· 567 680 const agent = useAgent() 568 681 const getPost = useGetPost() 569 682 570 - return React.useCallback(async () => { 683 + return useCallback(async () => { 571 684 try { 572 685 await Promise.all([ 573 686 queryClient.prefetchQuery({
+1 -1
src/components/forms/HostingProvider.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {toNiceDomain} from '#/lib/strings/url-helpers' 7 - import {ServerInputDialog} from '#/view/com/auth/server-input' 8 7 import {atoms as a, tokens, useTheme} from '#/alf' 9 8 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 9 import {useDialogControl} from '#/components/Dialog' 10 + import {ServerInputDialog} from '#/components/dialogs/ServerInput' 11 11 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 12 12 import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' 13 13 import {Text} from '#/components/Typography'
+284
src/components/forms/SegmentedControl.tsx
··· 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useLayoutEffect, 6 + useMemo, 7 + useState, 8 + } from 'react' 9 + import {type StyleProp, View, type ViewStyle} from 'react-native' 10 + import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 11 + 12 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 13 + import {atoms as a, native, platform, useTheme} from '#/alf' 14 + import { 15 + Button, 16 + type ButtonProps, 17 + ButtonText, 18 + type ButtonTextProps, 19 + } from '../Button' 20 + 21 + const InternalContext = createContext<{ 22 + type: 'tabs' | 'radio' 23 + size: 'small' | 'large' 24 + selectedValue: string 25 + selectedPosition: {width: number; x: number} | null 26 + onSelectValue: ( 27 + value: string, 28 + position: {width: number; x: number} | null, 29 + ) => void 30 + updatePosition: (position: {width: number; x: number}) => void 31 + } | null>(null) 32 + 33 + /** 34 + * Segmented control component. 35 + * 36 + * @example 37 + * ```tsx 38 + * <SegmentedControl.Root value={value} onChange={setValue}> 39 + * <SegmentedControl.Item value="one"> 40 + * <SegmentedControl.ItemText value="one"> 41 + * One 42 + * </SegmentedControl.ItemText> 43 + * </SegmentedControl.Item> 44 + * <SegmentedControl.Item value="two"> 45 + * <SegmentedControl.ItemText value="two"> 46 + * Two 47 + * </SegmentedControl.ItemText> 48 + * </SegmentedControl.Item> 49 + * </SegmentedControl.Root> 50 + * ``` 51 + */ 52 + export function Root<T extends string>({ 53 + label, 54 + type = 'radio', 55 + size = 'large', 56 + value, 57 + onChange, 58 + children, 59 + style, 60 + accessibilityHint, 61 + }: { 62 + label: string 63 + type: 'tabs' | 'radio' 64 + size?: 'small' | 'large' 65 + value: T 66 + onChange: (value: T) => void 67 + children: React.ReactNode 68 + style?: StyleProp<ViewStyle> 69 + accessibilityHint?: string 70 + }) { 71 + const t = useTheme() 72 + const [selectedPosition, setSelectedPosition] = useState<{ 73 + width: number 74 + x: number 75 + } | null>(null) 76 + 77 + const contextValue = useMemo(() => { 78 + return { 79 + type, 80 + size, 81 + selectedValue: value, 82 + selectedPosition, 83 + onSelectValue: ( 84 + val: string, 85 + position: {width: number; x: number} | null, 86 + ) => { 87 + onChange(val as T) 88 + if (position) setSelectedPosition(position) 89 + }, 90 + updatePosition: (position: {width: number; x: number}) => { 91 + setSelectedPosition(currPos => { 92 + if ( 93 + currPos && 94 + currPos.width === position.width && 95 + currPos.x === position.x 96 + ) { 97 + return currPos 98 + } 99 + return position 100 + }) 101 + }, 102 + } 103 + }, [value, selectedPosition, setSelectedPosition, onChange, type, size]) 104 + 105 + return ( 106 + <View 107 + accessibilityLabel={label} 108 + accessibilityHint={accessibilityHint ?? ''} 109 + style={[ 110 + a.w_full, 111 + a.flex_1, 112 + a.relative, 113 + a.flex_row, 114 + t.atoms.bg_contrast_50, 115 + {borderRadius: 14}, 116 + a.curve_continuous, 117 + a.p_xs, 118 + style, 119 + ]} 120 + role={type === 'tabs' ? 'tablist' : 'radiogroup'}> 121 + {selectedPosition !== null && ( 122 + <Slider x={selectedPosition.x} width={selectedPosition.width} /> 123 + )} 124 + <InternalContext.Provider value={contextValue}> 125 + {children} 126 + </InternalContext.Provider> 127 + </View> 128 + ) 129 + } 130 + 131 + const InternalItemContext = createContext<{ 132 + active: boolean 133 + pressed: boolean 134 + hovered: boolean 135 + focused: boolean 136 + } | null>(null) 137 + 138 + export function Item({ 139 + value, 140 + style, 141 + children, 142 + onPress: onPressProp, 143 + ...props 144 + }: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) { 145 + const [position, setPosition] = useState<{x: number; width: number} | null>( 146 + null, 147 + ) 148 + 149 + const ctx = useContext(InternalContext) 150 + if (!ctx) 151 + throw new Error( 152 + 'SegmentedControl.Item must be used within a SegmentedControl.Root', 153 + ) 154 + 155 + const active = ctx.selectedValue === value 156 + 157 + // update position if change was external, and not due to onPress 158 + const needsUpdate = 159 + active && 160 + position && 161 + (ctx.selectedPosition?.x !== position.x || 162 + ctx.selectedPosition?.width !== position.width) 163 + 164 + // can't wait for `useEffectEvent` 165 + const update = useNonReactiveCallback(() => { 166 + if (position) ctx.updatePosition(position) 167 + }) 168 + 169 + useLayoutEffect(() => { 170 + if (needsUpdate) { 171 + update() 172 + } 173 + }, [needsUpdate, update]) 174 + 175 + const onPress = useCallback( 176 + (evt: any) => { 177 + ctx.onSelectValue(value, position) 178 + onPressProp?.(evt) 179 + }, 180 + [ctx, value, position, onPressProp], 181 + ) 182 + 183 + return ( 184 + <View 185 + style={[a.flex_1, a.flex_row]} 186 + onLayout={evt => { 187 + const measuredPosition = { 188 + x: evt.nativeEvent.layout.x, 189 + width: evt.nativeEvent.layout.width, 190 + } 191 + if (!ctx.selectedPosition && active) { 192 + ctx.onSelectValue(value, measuredPosition) 193 + } 194 + setPosition(measuredPosition) 195 + }}> 196 + <Button 197 + {...props} 198 + onPress={onPress} 199 + role={ctx.type === 'tabs' ? 'tab' : 'radio'} 200 + accessibilityState={{selected: active}} 201 + style={[ 202 + a.flex_1, 203 + a.bg_transparent, 204 + a.px_sm, 205 + a.py_xs, 206 + {minHeight: ctx.size === 'large' ? 40 : 32}, 207 + style, 208 + ]}> 209 + {({pressed, hovered, focused}) => ( 210 + <InternalItemContext.Provider 211 + value={{active, pressed, hovered, focused}}> 212 + {children} 213 + </InternalItemContext.Provider> 214 + )} 215 + </Button> 216 + </View> 217 + ) 218 + } 219 + 220 + export function ItemText({style, ...props}: ButtonTextProps) { 221 + const t = useTheme() 222 + const ctx = useContext(InternalItemContext) 223 + if (!ctx) 224 + throw new Error( 225 + 'SegmentedControl.ItemText must be used within a SegmentedControl.Item', 226 + ) 227 + return ( 228 + <ButtonText 229 + {...props} 230 + style={[ 231 + a.text_center, 232 + a.text_md, 233 + a.font_medium, 234 + a.px_xs, 235 + ctx.active 236 + ? t.atoms.text 237 + : ctx.focused || ctx.hovered || ctx.pressed 238 + ? t.atoms.text_contrast_medium 239 + : t.atoms.text_contrast_low, 240 + style, 241 + ]} 242 + /> 243 + ) 244 + } 245 + 246 + function Slider({x, width}: {x: number; width: number}) { 247 + const t = useTheme() 248 + 249 + return ( 250 + <Animated.View 251 + layout={native(LinearTransition.easing(Easing.out(Easing.exp)))} 252 + style={[ 253 + a.absolute, 254 + a.curve_continuous, 255 + t.atoms.bg, 256 + { 257 + top: 4, 258 + bottom: 4, 259 + left: 0, 260 + width, 261 + borderRadius: 10, 262 + }, 263 + // TODO: new arch supports boxShadow on native 264 + // in the meantime this is an attempt to get close 265 + platform({ 266 + web: { 267 + boxShadow: '0px 2px 4px 0px #0000000D', 268 + }, 269 + ios: { 270 + shadowColor: '#000', 271 + shadowOffset: {width: 0, height: 2}, 272 + shadowOpacity: 0x0d / 0xff, 273 + shadowRadius: 4, 274 + }, 275 + android: {elevation: 0.25}, 276 + }), 277 + platform({ 278 + native: [{left: x}], 279 + web: [{transform: [{translateX: x}]}, a.transition_transform], 280 + }), 281 + ]} 282 + /> 283 + ) 284 + }
+157 -74
src/components/forms/Toggle.tsx src/components/forms/Toggle/index.tsx
··· 1 - import React from 'react' 2 - import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 - import Animated, {LinearTransition} from 'react-native-reanimated' 1 + import {createContext, useCallback, useContext, useMemo} from 'react' 2 + import { 3 + Pressable, 4 + type PressableProps, 5 + type StyleProp, 6 + View, 7 + type ViewStyle, 8 + } from 'react-native' 9 + import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 4 10 5 11 import {HITSLOP_10} from '#/lib/constants' 12 + import {useHaptics} from '#/lib/haptics' 6 13 import {isNative} from '#/platform/detection' 7 14 import { 8 15 atoms as a, 9 16 native, 17 + platform, 10 18 type TextStyleProp, 11 19 useTheme, 12 20 type ViewStyleProp, ··· 15 23 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 16 24 import {Text} from '#/components/Typography' 17 25 26 + export * from './Panel' 27 + 18 28 export type ItemState = { 19 29 name: string 20 30 selected: boolean ··· 25 35 focused: boolean 26 36 } 27 37 28 - const ItemContext = React.createContext<ItemState>({ 38 + const ItemContext = createContext<ItemState>({ 29 39 name: '', 30 40 selected: false, 31 41 disabled: false, ··· 36 46 }) 37 47 ItemContext.displayName = 'ToggleItemContext' 38 48 39 - const GroupContext = React.createContext<{ 49 + const GroupContext = createContext<{ 40 50 values: string[] 41 51 disabled: boolean 42 52 type: 'radio' | 'checkbox' ··· 70 80 onChange?: (selected: boolean) => void 71 81 isInvalid?: boolean 72 82 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 83 + hitSlop?: PressableProps['hitSlop'] 73 84 } 74 85 75 86 export function useItemContext() { 76 - return React.useContext(ItemContext) 87 + return useContext(ItemContext) 77 88 } 78 89 79 90 export function Group({ ··· 88 99 }: GroupProps) { 89 100 const groupRole = type === 'radio' ? 'radiogroup' : undefined 90 101 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 91 - const [maxReached, setMaxReached] = React.useState(false) 92 102 93 - const setFieldValue = React.useCallback< 103 + const setFieldValue = useCallback< 94 104 (props: {name: string; value: boolean}) => void 95 105 >( 96 106 ({name, value}) => { ··· 105 115 [type, onChange, values], 106 116 ) 107 117 108 - React.useEffect(() => { 109 - if (type === 'checkbox') { 110 - if ( 111 - maxSelections && 112 - values.length >= maxSelections && 113 - maxReached === false 114 - ) { 115 - setMaxReached(true) 116 - } else if ( 117 - maxSelections && 118 - values.length < maxSelections && 119 - maxReached === true 120 - ) { 121 - setMaxReached(false) 122 - } 123 - } 124 - }, [type, values.length, maxSelections, maxReached, setMaxReached]) 118 + const maxReached = !!( 119 + type === 'checkbox' && 120 + maxSelections && 121 + values.length >= maxSelections 122 + ) 125 123 126 - const context = React.useMemo( 124 + const context = useMemo( 127 125 () => ({ 128 126 values, 129 127 type, ··· 170 168 disabled: groupDisabled, 171 169 setFieldValue, 172 170 maxSelectionsReached, 173 - } = React.useContext(GroupContext) 171 + } = useContext(GroupContext) 174 172 const { 175 173 state: hovered, 176 174 onIn: onHoverIn, ··· 182 180 onOut: onPressOut, 183 181 } = useInteractionState() 184 182 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 183 + const playHaptic = useHaptics() 185 184 186 185 const role = groupType === 'radio' ? 'radio' : type 187 186 const selected = selectedValues.includes(name) || !!value 188 187 const disabled = 189 188 groupDisabled || itemDisabled || (!selected && maxSelectionsReached) 190 189 191 - const onPress = React.useCallback(() => { 190 + const onPress = useCallback(() => { 191 + playHaptic('Light') 192 192 const next = !selected 193 193 setFieldValue({name, value: next}) 194 194 onChange?.(next) 195 - }, [name, selected, onChange, setFieldValue]) 195 + }, [playHaptic, name, selected, onChange, setFieldValue]) 196 196 197 - const state = React.useMemo( 197 + const state = useMemo( 198 198 () => ({ 199 199 name, 200 200 selected, ··· 250 250 style={[ 251 251 a.font_semi_bold, 252 252 a.leading_tight, 253 + a.user_select_none, 253 254 { 254 - userSelect: 'none', 255 255 color: disabled 256 256 ? t.atoms.text_contrast_low.color 257 257 : t.atoms.text_contrast_high.color, ··· 287 287 288 288 if (selected) { 289 289 base.push({ 290 - backgroundColor: t.palette.primary_25, 290 + backgroundColor: t.palette.primary_500, 291 291 borderColor: t.palette.primary_500, 292 292 }) 293 293 294 294 if (hovered) { 295 295 baseHover.push({ 296 - backgroundColor: t.palette.primary_100, 297 - borderColor: t.palette.primary_600, 296 + backgroundColor: t.palette.primary_400, 297 + borderColor: t.palette.primary_400, 298 298 }) 299 299 } 300 300 } else { 301 + base.push({ 302 + backgroundColor: t.palette.contrast_25, 303 + borderColor: t.palette.contrast_100, 304 + }) 305 + 301 306 if (hovered) { 302 307 baseHover.push({ 303 308 backgroundColor: t.palette.contrast_50, 304 - borderColor: t.palette.contrast_500, 309 + borderColor: t.palette.contrast_200, 305 310 }) 306 311 } 307 312 } ··· 318 323 borderColor: t.palette.negative_600, 319 324 }) 320 325 } 326 + 327 + if (selected) { 328 + base.push({ 329 + backgroundColor: t.palette.negative_500, 330 + borderColor: t.palette.negative_500, 331 + }) 332 + 333 + if (hovered) { 334 + baseHover.push({ 335 + backgroundColor: t.palette.negative_400, 336 + borderColor: t.palette.negative_400, 337 + }) 338 + } 339 + } 321 340 } 322 341 323 342 if (disabled) { ··· 325 344 backgroundColor: t.palette.contrast_100, 326 345 borderColor: t.palette.contrast_400, 327 346 }) 347 + 348 + if (selected) { 349 + base.push({ 350 + backgroundColor: t.palette.primary_100, 351 + borderColor: t.palette.contrast_400, 352 + }) 353 + } 328 354 } 329 355 330 356 return { ··· 350 376 style={[ 351 377 a.justify_center, 352 378 a.align_center, 353 - a.rounded_xs, 354 379 t.atoms.border_contrast_high, 380 + a.transition_color, 355 381 { 356 382 borderWidth: 1, 357 383 height: 24, 358 384 width: 24, 385 + borderRadius: 6, 359 386 }, 360 387 baseStyles, 361 388 hovered ? baseHoverStyles : {}, 362 389 ]}> 363 - {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} 390 + {selected && <Checkmark width={14} fill={t.palette.white} />} 364 391 </View> 365 392 ) 366 393 } 367 394 368 395 export function Switch() { 369 396 const t = useTheme() 370 - const {selected, hovered, focused, disabled, isInvalid} = useItemContext() 371 - const {baseStyles, baseHoverStyles, indicatorStyles} = 372 - createSharedToggleStyles({ 373 - theme: t, 374 - hovered, 375 - focused, 376 - selected, 377 - disabled, 378 - isInvalid, 379 - }) 397 + const {selected, hovered, disabled, isInvalid} = useItemContext() 398 + const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => { 399 + const base: ViewStyle[] = [] 400 + const baseHover: ViewStyle[] = [] 401 + const indicator: ViewStyle[] = [] 402 + 403 + if (selected) { 404 + base.push({ 405 + backgroundColor: t.palette.primary_500, 406 + }) 407 + 408 + if (hovered) { 409 + baseHover.push({ 410 + backgroundColor: t.palette.primary_400, 411 + }) 412 + } 413 + } else { 414 + base.push({ 415 + backgroundColor: t.palette.contrast_200, 416 + }) 417 + 418 + if (hovered) { 419 + baseHover.push({ 420 + backgroundColor: t.palette.contrast_100, 421 + }) 422 + } 423 + } 424 + 425 + if (isInvalid) { 426 + base.push({ 427 + backgroundColor: t.palette.negative_200, 428 + }) 429 + 430 + if (hovered) { 431 + baseHover.push({ 432 + backgroundColor: t.palette.negative_100, 433 + }) 434 + } 435 + 436 + if (selected) { 437 + base.push({ 438 + backgroundColor: t.palette.negative_500, 439 + }) 440 + 441 + if (hovered) { 442 + baseHover.push({ 443 + backgroundColor: t.palette.negative_400, 444 + }) 445 + } 446 + } 447 + } 448 + 449 + if (disabled) { 450 + base.push({ 451 + backgroundColor: t.palette.contrast_50, 452 + }) 453 + 454 + if (selected) { 455 + base.push({ 456 + backgroundColor: t.palette.primary_100, 457 + }) 458 + } 459 + } 460 + 461 + return { 462 + baseStyles: base, 463 + baseHoverStyles: disabled ? [] : baseHover, 464 + indicatorStyles: indicator, 465 + } 466 + }, [t, hovered, disabled, selected, isInvalid]) 467 + 380 468 return ( 381 469 <View 382 470 style={[ 383 471 a.relative, 384 472 a.rounded_full, 385 473 t.atoms.bg, 386 - t.atoms.border_contrast_high, 387 474 { 388 - borderWidth: 1, 389 - height: 24, 390 - width: 36, 475 + height: 28, 476 + width: 48, 391 477 padding: 3, 392 478 }, 479 + a.transition_color, 393 480 baseStyles, 394 481 hovered ? baseHoverStyles : {}, 395 482 ]}> 396 483 <Animated.View 397 - layout={LinearTransition.duration(100)} 484 + layout={LinearTransition.duration( 485 + platform({ 486 + web: 100, 487 + default: 200, 488 + }), 489 + ).easing(Easing.inOut(Easing.cubic))} 398 490 style={[ 399 491 a.rounded_full, 400 492 { 401 - height: 16, 402 - width: 16, 493 + backgroundColor: t.palette.white, 494 + height: 22, 495 + width: 22, 403 496 }, 404 - selected 405 - ? { 406 - backgroundColor: t.palette.primary_500, 407 - alignSelf: 'flex-end', 408 - } 409 - : { 410 - backgroundColor: t.palette.contrast_400, 411 - alignSelf: 'flex-start', 412 - }, 497 + selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'}, 413 498 indicatorStyles, 414 499 ]} 415 500 /> ··· 420 505 export function Radio() { 421 506 const t = useTheme() 422 507 const {selected, hovered, focused, disabled, isInvalid} = 423 - React.useContext(ItemContext) 508 + useContext(ItemContext) 424 509 const {baseStyles, baseHoverStyles, indicatorStyles} = 425 510 createSharedToggleStyles({ 426 511 theme: t, ··· 437 522 a.align_center, 438 523 a.rounded_full, 439 524 t.atoms.border_contrast_high, 525 + a.transition_color, 440 526 { 441 527 borderWidth: 1, 442 - height: 24, 443 - width: 24, 528 + height: 25, 529 + width: 25, 530 + margin: -1, 444 531 }, 445 532 baseStyles, 446 533 hovered ? baseHoverStyles : {}, 447 534 ]}> 448 - {selected ? ( 535 + {selected && ( 449 536 <View 450 537 style={[ 451 538 a.absolute, 452 539 a.rounded_full, 453 - {height: 16, width: 16}, 454 - selected 455 - ? { 456 - backgroundColor: t.palette.primary_500, 457 - } 458 - : {}, 540 + {height: 12, width: 12}, 541 + {backgroundColor: t.palette.white}, 459 542 indicatorStyles, 460 543 ]} 461 544 /> 462 - ) : null} 545 + )} 463 546 </View> 464 547 ) 465 548 }
+120
src/components/forms/Toggle/Panel.tsx
··· 1 + import {createContext, useContext} from 'react' 2 + import {View, type ViewStyle} from 'react-native' 3 + 4 + import {atoms as a, tokens, useTheme} from '#/alf' 5 + import {type Props as SVGIconProps} from '#/components/icons/common' 6 + import {Text} from '#/components/Typography' 7 + 8 + const PanelContext = createContext<{active: boolean}>({active: false}) 9 + 10 + /** 11 + * A nice container for Toggles. See the Threadgate dialog for an example. 12 + */ 13 + export function Panel({ 14 + children, 15 + active = false, 16 + adjacent, 17 + }: { 18 + children: React.ReactNode 19 + active?: boolean 20 + adjacent?: 'leading' | 'trailing' | 'both' 21 + }) { 22 + const t = useTheme() 23 + 24 + const leading = adjacent === 'leading' || adjacent === 'both' 25 + const trailing = adjacent === 'trailing' || adjacent === 'both' 26 + const rounding = { 27 + borderTopLeftRadius: leading 28 + ? tokens.borderRadius.xs 29 + : tokens.borderRadius.md, 30 + borderTopRightRadius: leading 31 + ? tokens.borderRadius.xs 32 + : tokens.borderRadius.md, 33 + borderBottomLeftRadius: trailing 34 + ? tokens.borderRadius.xs 35 + : tokens.borderRadius.md, 36 + borderBottomRightRadius: trailing 37 + ? tokens.borderRadius.xs 38 + : tokens.borderRadius.md, 39 + } satisfies ViewStyle 40 + 41 + return ( 42 + <View 43 + style={[ 44 + a.w_full, 45 + a.flex_row, 46 + a.align_center, 47 + a.gap_sm, 48 + a.px_md, 49 + a.py_md, 50 + {minHeight: tokens.space._2xl + tokens.space.md * 2}, 51 + rounding, 52 + active 53 + ? {backgroundColor: t.palette.primary_50} 54 + : t.atoms.bg_contrast_50, 55 + ]}> 56 + <PanelContext value={{active}}>{children}</PanelContext> 57 + </View> 58 + ) 59 + } 60 + 61 + export function PanelText({ 62 + children, 63 + icon, 64 + }: { 65 + children: React.ReactNode 66 + icon?: React.ComponentType<SVGIconProps> 67 + }) { 68 + const t = useTheme() 69 + const ctx = useContext(PanelContext) 70 + 71 + const text = ( 72 + <Text 73 + style={[ 74 + a.text_md, 75 + a.flex_1, 76 + ctx.active 77 + ? [a.font_medium, t.atoms.text] 78 + : [t.atoms.text_contrast_medium], 79 + ]}> 80 + {children} 81 + </Text> 82 + ) 83 + 84 + if (icon) { 85 + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 86 + return ( 87 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 88 + <PanelIcon icon={icon} /> 89 + {text} 90 + </View> 91 + ) 92 + } 93 + 94 + return text 95 + } 96 + 97 + export function PanelIcon({ 98 + icon: Icon, 99 + }: { 100 + icon: React.ComponentType<SVGIconProps> 101 + }) { 102 + const t = useTheme() 103 + const ctx = useContext(PanelContext) 104 + return ( 105 + <Icon 106 + style={[ 107 + ctx.active ? t.atoms.text : t.atoms.text_contrast_medium, 108 + a.flex_shrink_0, 109 + ]} 110 + size="md" 111 + /> 112 + ) 113 + } 114 + 115 + /** 116 + * A group of panels. TODO: auto-leading/trailing 117 + */ 118 + export function PanelGroup({children}: {children: React.ReactNode}) { 119 + return <View style={[a.w_full, a.gap_2xs]}>{children}</View> 120 + }
+12 -3
src/components/forms/ToggleButton.tsx
··· 1 - import React from 'react' 1 + import {useMemo} from 'react' 2 2 import { 3 3 type AccessibilityProps, 4 4 type TextStyle, ··· 20 20 multiple?: boolean 21 21 } 22 22 23 + /** 24 + * @deprecated - use SegmentedControl 25 + */ 23 26 export function Group({children, multiple, ...props}: GroupProps) { 24 27 const t = useTheme() 25 28 return ( ··· 39 42 ) 40 43 } 41 44 45 + /** 46 + * @deprecated - use SegmentedControl 47 + */ 42 48 export function Button({children, ...props}: ItemProps) { 43 49 return ( 44 50 <Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}> ··· 51 57 const t = useTheme() 52 58 const state = Toggle.useItemContext() 53 59 54 - const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => { 60 + const {baseStyles, hoverStyles, activeStyles} = useMemo(() => { 55 61 const base: ViewStyle[] = [] 56 62 const hover: ViewStyle[] = [] 57 63 const active: ViewStyle[] = [] ··· 112 118 ) 113 119 } 114 120 121 + /** 122 + * @deprecated - use SegmentedControl 123 + */ 115 124 export function ButtonText({children}: {children: React.ReactNode}) { 116 125 const t = useTheme() 117 126 const state = Toggle.useItemContext() 118 127 119 - const textStyles = React.useMemo(() => { 128 + const textStyles = useMemo(() => { 120 129 const text: TextStyle[] = [] 121 130 if (state.selected) { 122 131 text.push(t.atoms.text_inverted)
+7
src/components/icons/Chevron.tsx
··· 19 19 export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 20 20 path: 'M11.293 4.293a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1-1.414 1.414L12 6.414 8.707 9.707a1 1 0 0 1-1.414-1.414l4-4Zm-4 10a1 1 0 0 1 1.414 0L12 17.586l3.293-3.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z', 21 21 }) 22 + 23 + /** 24 + * NOTE: Use with size `2xs` 25 + */ 26 + export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 27 + path: 'M10.928 18.882c.757.499 1.786.417 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z', 28 + })
+1
src/components/icons/common.tsx
··· 13 13 } & Omit<SvgProps, 'style' | 'size'> 14 14 15 15 export const sizes = { 16 + '2xs': 8, 16 17 xs: 12, 17 18 sm: 16, 18 19 md: 20,
+1 -1
src/components/verification/VerificationsDialog.tsx
··· 34 34 verificationState: FullVerificationState 35 35 }) { 36 36 return ( 37 - <Dialog.Outer control={control}> 37 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 38 38 <Dialog.Handle /> 39 39 <Inner 40 40 control={control}
+9 -9
src/lib/media/manip.ts
··· 85 85 return 86 86 } 87 87 88 - // we're currently relying on the fact our CDN only serves pngs 88 + // we're currently relying on the fact our CDN only serves jpegs 89 89 // -prf 90 - const imageUri = await downloadImage(uri, createPath('png'), 5e3) 91 - const imagePath = await moveToPermanentPath(imageUri, '.png') 90 + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 91 + const imagePath = await moveToPermanentPath(imageUri, '.jpg') 92 92 safeDeleteAsync(imageUri) 93 93 await Sharing.shareAsync(imagePath, { 94 - mimeType: 'image/png', 95 - UTI: 'image/png', 94 + mimeType: 'image/jpeg', 95 + UTI: 'image/jpeg', 96 96 }) 97 97 } 98 98 ··· 101 101 export async function saveImageToMediaLibrary({uri}: {uri: string}) { 102 102 // download the file to cache 103 103 // NOTE 104 - // assuming PNG 105 - // we're currently relying on the fact our CDN only serves pngs 104 + // assuming JPEG 105 + // we're currently relying on the fact our CDN only serves jpegs 106 106 // -prf 107 - const imageUri = await downloadImage(uri, createPath('png'), 5e3) 108 - const imagePath = await moveToPermanentPath(imageUri, '.png') 107 + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 108 + const imagePath = await moveToPermanentPath(imageUri, '.jpg') 109 109 110 110 // save 111 111 try {
+1 -1
src/lib/media/picker.e2e.tsx
··· 67 67 68 68 return { 69 69 path: item.path, 70 - mime: item.mime, 70 + mime: item.mimeType, 71 71 size: item.size, 72 72 width: item.width, 73 73 height: item.height,
+4 -1
src/lib/media/picker.tsx
··· 1 1 import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool' 2 2 import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker' 3 + import {t} from '@lingui/macro' 3 4 4 5 export { 5 6 openPicker, ··· 31 32 32 33 export async function openCropper(opts: OpenCropperOptions) { 33 34 const item = await ExpoImageCropTool.openCropperAsync({ 35 + doneButtonText: t`Done`, 36 + cancelButtonText: t`Cancel`, 34 37 ...opts, 35 38 format: 'jpeg', 36 39 }) 37 40 38 41 return { 39 42 path: item.path, 40 - mime: item.mime, 43 + mime: item.mimeType, 41 44 size: item.size, 42 45 width: item.width, 43 46 height: item.height,
+6 -2
src/lib/strings/time.ts
··· 1 1 import {type I18n} from '@lingui/core' 2 2 3 - export function niceDate(i18n: I18n, date: number | string | Date) { 3 + export function niceDate( 4 + i18n: I18n, 5 + date: number | string | Date, 6 + dateStyle: 'short' | 'medium' | 'long' | 'full' = 'long', 7 + ) { 4 8 const d = new Date(date) 5 9 6 10 return i18n.date(d, { 7 - dateStyle: 'long', 11 + dateStyle, 8 12 timeStyle: 'short', 9 13 }) 10 14 }
+35 -35
src/locale/locales/en/messages.po
··· 733 733 msgid "Add app password" 734 734 msgstr "" 735 735 736 - #: src/screens/Settings/AppPasswords.tsx:75 737 - #: src/screens/Settings/AppPasswords.tsx:83 736 + #: src/screens/Settings/AppPasswords.tsx:73 737 + #: src/screens/Settings/AppPasswords.tsx:81 738 738 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:111 739 739 msgid "Add App Password" 740 740 msgstr "" ··· 937 937 msgid "Allow replies from:" 938 938 msgstr "" 939 939 940 - #: src/screens/Settings/AppPasswords.tsx:200 940 + #: src/screens/Settings/AppPasswords.tsx:199 941 941 msgid "Allows access to direct messages" 942 942 msgstr "" 943 943 ··· 1129 1129 msgid "App Password" 1130 1130 msgstr "" 1131 1131 1132 - #: src/screens/Settings/AppPasswords.tsx:147 1132 + #: src/screens/Settings/AppPasswords.tsx:145 1133 1133 msgctxt "toast" 1134 1134 msgid "App password deleted" 1135 1135 msgstr "" ··· 1152 1152 msgstr "" 1153 1153 1154 1154 #: src/Navigation.tsx:351 1155 - #: src/screens/Settings/AppPasswords.tsx:51 1155 + #: src/screens/Settings/AppPasswords.tsx:49 1156 1156 msgid "App Passwords" 1157 1157 msgstr "" 1158 1158 ··· 1213 1213 msgid "Archived post" 1214 1214 msgstr "" 1215 1215 1216 - #: src/screens/Settings/AppPasswords.tsx:209 1216 + #: src/screens/Settings/AppPasswords.tsx:208 1217 1217 msgid "Are you sure you want to delete the app password \"{0}\"?" 1218 1218 msgstr "" 1219 1219 ··· 1641 1641 #: src/screens/Deactivated.tsx:158 1642 1642 #: src/screens/Profile/Header/EditProfileDialog.tsx:218 1643 1643 #: src/screens/Profile/Header/EditProfileDialog.tsx:226 1644 - #: src/screens/Search/Shell.tsx:369 1644 + #: src/screens/Search/Shell.tsx:370 1645 1645 #: src/screens/Settings/AppIconSettings/index.tsx:44 1646 1646 #: src/screens/Settings/AppIconSettings/index.tsx:230 1647 1647 #: src/screens/Settings/components/ChangeHandleDialog.tsx:78 ··· 2532 2532 msgid "Create user list" 2533 2533 msgstr "" 2534 2534 2535 - #: src/screens/Settings/AppPasswords.tsx:174 2535 + #: src/screens/Settings/AppPasswords.tsx:172 2536 2536 msgid "Created {0}" 2537 2537 msgstr "" 2538 2538 ··· 2624 2624 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:736 2625 2625 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 2626 2626 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:280 2627 - #: src/screens/Settings/AppPasswords.tsx:212 2627 + #: src/screens/Settings/AppPasswords.tsx:211 2628 2628 #: src/screens/StarterPack/StarterPackScreen.tsx:601 2629 2629 #: src/screens/StarterPack/StarterPackScreen.tsx:690 2630 2630 #: src/screens/StarterPack/StarterPackScreen.tsx:762 ··· 2640 2640 msgid "Delete Account <0>\"</0><1>{0}</1><2>\"</2>" 2641 2641 msgstr "" 2642 2642 2643 - #: src/screens/Settings/AppPasswords.tsx:187 2643 + #: src/screens/Settings/AppPasswords.tsx:185 2644 2644 msgid "Delete app password" 2645 2645 msgstr "" 2646 2646 2647 - #: src/screens/Settings/AppPasswords.tsx:207 2647 + #: src/screens/Settings/AppPasswords.tsx:206 2648 2648 msgid "Delete app password?" 2649 2649 msgstr "" 2650 2650 ··· 2884 2884 msgid "Does not include nudity." 2885 2885 msgstr "" 2886 2886 2887 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:517 2887 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:522 2888 2888 msgid "Domain verified!" 2889 2889 msgstr "" 2890 2890 ··· 3477 3477 msgid "Failed to add to starter pack" 3478 3478 msgstr "" 3479 3479 3480 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:597 3480 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:602 3481 3481 msgid "Failed to change handle. Please try again." 3482 3482 msgstr "" 3483 3483 ··· 3790 3790 msgid "Find people to follow" 3791 3791 msgstr "" 3792 3792 3793 - #: src/screens/Search/Shell.tsx:525 3793 + #: src/screens/Search/Shell.tsx:526 3794 3794 msgid "Find posts, users, and feeds on Bluesky" 3795 3795 msgstr "" 3796 3796 ··· 4231 4231 msgid "Handle" 4232 4232 msgstr "" 4233 4233 4234 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:601 4234 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:606 4235 4235 msgid "Handle already taken. Please try a different one." 4236 4236 msgstr "" 4237 4237 ··· 4240 4240 msgid "Handle changed!" 4241 4241 msgstr "" 4242 4242 4243 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:605 4243 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:610 4244 4244 msgid "Handle too long. Please try a shorter one." 4245 4245 msgstr "" 4246 4246 ··· 4620 4620 msgid "Invalid 2FA confirmation code." 4621 4621 msgstr "" 4622 4622 4623 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:607 4623 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:612 4624 4624 msgid "Invalid handle. Please try a different one." 4625 4625 msgstr "" 4626 4626 ··· 5513 5513 msgid "Never lose access to your followers or data." 5514 5514 msgstr "" 5515 5515 5516 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:577 5516 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:582 5517 5517 msgid "Nevermind, create a handle for me" 5518 5518 msgstr "" 5519 5519 ··· 5656 5656 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." 5657 5657 msgstr "" 5658 5658 5659 - #: src/screens/Settings/AppPasswords.tsx:108 5659 + #: src/screens/Settings/AppPasswords.tsx:106 5660 5660 msgid "No app passwords yet" 5661 5661 msgstr "" 5662 5662 ··· 5980 5980 #: src/components/Lists.tsx:173 5981 5981 #: src/components/StarterPack/ProfileStarterPacks.tsx:328 5982 5982 #: src/components/StarterPack/ProfileStarterPacks.tsx:337 5983 - #: src/screens/Settings/AppPasswords.tsx:59 5983 + #: src/screens/Settings/AppPasswords.tsx:57 5984 5984 #: src/screens/Settings/components/ChangeHandleDialog.tsx:106 5985 5985 #: src/view/screens/Profile.tsx:125 5986 5986 msgid "Oops!" ··· 6845 6845 msgid "Quotes of this post" 6846 6846 msgstr "" 6847 6847 6848 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:610 6848 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:615 6849 6849 msgid "Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again." 6850 6850 msgstr "" 6851 6851 ··· 7453 7453 7454 7454 #: src/screens/Profile/ProfileFeed/index.tsx:93 7455 7455 #: src/screens/ProfileList/components/ErrorScreen.tsx:35 7456 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:569 7456 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:574 7457 7457 #: src/screens/VideoFeed/index.tsx:1163 7458 7458 #: src/view/screens/NotFound.tsx:60 7459 7459 msgid "Returns to previous page" ··· 7571 7571 #: src/components/forms/SearchInput.tsx:34 7572 7572 #: src/components/forms/SearchInput.tsx:36 7573 7573 #: src/screens/Search/Shell.tsx:327 7574 - #: src/screens/Search/Shell.tsx:513 7574 + #: src/screens/Search/Shell.tsx:514 7575 7575 #: src/view/shell/bottom-bar/BottomBar.tsx:198 7576 7576 msgid "Search" 7577 7577 msgstr "" ··· 7731 7731 msgid "Select account" 7732 7732 msgstr "" 7733 7733 7734 - #: src/components/AppLanguageDropdown.tsx:60 7734 + #: src/components/AppLanguageDropdown.tsx:61 7735 7735 msgid "Select an app language" 7736 7736 msgstr "" 7737 7737 ··· 8868 8868 msgid "There was an issue fetching the list. Tap here to try again." 8869 8869 msgstr "" 8870 8870 8871 - #: src/screens/Settings/AppPasswords.tsx:60 8871 + #: src/screens/Settings/AppPasswords.tsx:58 8872 8872 msgid "There was an issue fetching your app passwords" 8873 8873 msgstr "" 8874 8874 ··· 9038 9038 msgid "This feed is no longer online. We are showing <0>Discover</0> instead." 9039 9039 msgstr "" 9040 9040 9041 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:603 9041 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:608 9042 9042 msgid "This handle is reserved. Please try a different one." 9043 9043 msgstr "" 9044 9044 ··· 9558 9558 msgid "Update email" 9559 9559 msgstr "" 9560 9560 9561 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:536 9562 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:557 9561 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:541 9562 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:562 9563 9563 msgid "Update to {domain}" 9564 9564 msgstr "" 9565 9565 ··· 9621 9621 msgid "Uploading video..." 9622 9622 msgstr "" 9623 9623 9624 - #: src/screens/Settings/AppPasswords.tsx:67 9624 + #: src/screens/Settings/AppPasswords.tsx:65 9625 9625 msgid "Use app passwords to sign in to other Bluesky clients without giving full access to your account or password." 9626 9626 msgstr "" 9627 9627 9628 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:568 9628 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:573 9629 9629 msgid "Use default provider" 9630 9630 msgstr "" 9631 9631 ··· 9785 9785 msgid "Verify code" 9786 9786 msgstr "" 9787 9787 9788 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:538 9789 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:559 9788 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:543 9789 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:564 9790 9790 msgid "Verify DNS Record" 9791 9791 msgstr "" 9792 9792 ··· 9804 9804 msgid "Verify now" 9805 9805 msgstr "" 9806 9806 9807 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:539 9808 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:561 9807 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:544 9808 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:566 9809 9809 msgid "Verify Text File" 9810 9810 msgstr "" 9811 9811 ··· 10817 10817 msgid "Your choice will be remembered for future links. You can change it at any time in settings." 10818 10818 msgstr "" 10819 10819 10820 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:523 10820 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:528 10821 10821 msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account." 10822 10822 msgstr "" 10823 10823
+1 -1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 587 587 <BackdatedPostIndicator post={post} /> 588 588 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 589 589 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 590 - {niceDate(i18n, post.indexedAt)} 590 + {niceDate(i18n, post.indexedAt, 'medium')} 591 591 </Text> 592 592 {isRootPost && ( 593 593 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+1 -1
src/screens/Profile/Header/Handle.tsx
··· 37 37 pointerEvents={disableTaps ? 'none' : isIOS ? 'auto' : 'box-none'}> 38 38 <NewskieDialog profile={profile} disabled={disableTaps} /> 39 39 {profile.viewer?.followedBy && !blockHide ? ( 40 - <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> 40 + <View style={[t.atoms.bg_contrast_50, a.rounded_xs, a.px_sm, a.py_xs]}> 41 41 <Text style={[t.atoms.text, a.text_sm]}> 42 42 <Trans>Follows you</Trans> 43 43 </Text>
+2 -1
src/screens/Search/Shell.tsx
··· 362 362 size="large" 363 363 variant="ghost" 364 364 color="secondary" 365 - style={[a.px_sm, a.rounded_sm]} 365 + shape="rectangular" 366 + style={[a.px_sm]} 366 367 onPress={onPressCancelSearch} 367 368 hitSlop={HITSLOP_10}> 368 369 <ButtonText>
+2 -3
src/screens/Settings/AppPasswords.tsx
··· 5 5 FadeOut, 6 6 LayoutAnimationConfig, 7 7 LinearTransition, 8 - StretchOutY, 9 8 } from 'react-native-reanimated' 10 9 import {type ComAtprotoServerListAppPasswords} from '@atproto/api' 11 10 import {msg, Trans} from '@lingui/macro' ··· 14 13 15 14 import {type CommonNavigatorParams} from '#/lib/routes/types' 16 15 import {cleanError} from '#/lib/strings/errors' 17 - import {isWeb} from '#/platform/detection' 18 16 import { 19 17 useAppPasswordDeleteMutation, 20 18 useAppPasswordsQuery, ··· 94 92 key={appPassword.name} 95 93 style={a.w_full} 96 94 entering={FadeIn} 97 - exiting={isWeb ? FadeOut : StretchOutY} 95 + exiting={FadeOut} 98 96 layout={LinearTransition.delay(150)}> 99 97 <SettingsList.Item> 100 98 <AppPasswordCard appPassword={appPassword} /> ··· 188 186 variant="ghost" 189 187 color="negative" 190 188 size="small" 189 + shape="square" 191 190 style={[a.bg_transparent]} 192 191 onPress={() => deleteControl.open()}> 193 192 <ButtonIcon icon={TrashIcon} />
+33 -40
src/screens/Settings/AppearanceSettings.tsx
··· 15 15 import {isNative} from '#/platform/detection' 16 16 import {useSetThemePrefs, useThemePrefs} from '#/state/shell' 17 17 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 18 - import {atoms as a, native, useAlf, useTheme} from '#/alf' 19 - import * as ToggleButton from '#/components/forms/ToggleButton' 18 + import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 19 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 20 20 import {type Props as SVGIconProps} from '#/components/icons/common' 21 21 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' 22 22 import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' ··· 36 36 const {setColorMode, setDarkTheme} = useSetThemePrefs() 37 37 38 38 const onChangeAppearance = useCallback( 39 - (keys: string[]) => { 40 - const appearance = keys.find(key => key !== colorMode) as 41 - | 'system' 42 - | 'light' 43 - | 'dark' 44 - | undefined 45 - if (!appearance) return 46 - setColorMode(appearance) 39 + (value: 'light' | 'system' | 'dark') => { 40 + setColorMode(value) 47 41 }, 48 - [setColorMode, colorMode], 42 + [setColorMode], 49 43 ) 50 44 51 45 const onChangeDarkTheme = useCallback( 52 - (keys: string[]) => { 53 - const theme = keys.find(key => key !== darkTheme) as 54 - | 'dim' 55 - | 'dark' 56 - | undefined 57 - if (!theme) return 58 - setDarkTheme(theme) 46 + (value: 'dim' | 'dark') => { 47 + setDarkTheme(value) 59 48 }, 60 - [setDarkTheme, darkTheme], 49 + [setDarkTheme], 61 50 ) 62 51 63 52 const onChangeFontFamily = useCallback( 64 - (values: string[]) => { 65 - const next = values[0] === 'system' ? 'system' : 'theme' 66 - fonts.setFontFamily(next) 53 + (value: 'system' | 'theme') => { 54 + fonts.setFontFamily(value) 67 55 }, 68 56 [fonts], 69 57 ) 70 58 71 59 const onChangeFontScale = useCallback( 72 - (values: string[]) => { 73 - const next = values[0] || ('0' as any) 74 - fonts.setFontScale(next) 60 + (value: Alf['fonts']['scale']) => { 61 + fonts.setFontScale(value) 75 62 }, 76 63 [fonts], 77 64 ) ··· 107 94 name: 'dark', 108 95 }, 109 96 ]} 110 - values={[colorMode]} 97 + value={colorMode} 111 98 onChange={onChangeAppearance} 112 99 /> 113 100 ··· 128 115 name: 'dark', 129 116 }, 130 117 ]} 131 - values={[darkTheme ?? 'dim']} 118 + value={darkTheme ?? 'dim'} 132 119 onChange={onChangeDarkTheme} 133 120 /> 134 121 </Animated.View> ··· 153 140 name: 'theme', 154 141 }, 155 142 ]} 156 - values={[fonts.family]} 143 + value={fonts.family} 157 144 onChange={onChangeFontFamily} 158 145 /> 159 146 ··· 174 161 name: '1', 175 162 }, 176 163 ]} 177 - values={[fonts.scale]} 164 + value={fonts.scale} 178 165 onChange={onChangeFontScale} 179 166 /> 180 167 ··· 192 179 ) 193 180 } 194 181 195 - export function AppearanceToggleButtonGroup({ 182 + export function AppearanceToggleButtonGroup<T extends string>({ 196 183 title, 197 184 description, 198 185 icon: Icon, 199 186 items, 200 - values, 187 + value, 201 188 onChange, 202 189 }: { 203 190 title: string ··· 205 192 icon: React.ComponentType<SVGIconProps> 206 193 items: { 207 194 label: string 208 - name: string 195 + name: T 209 196 }[] 210 - values: string[] 211 - onChange: (values: string[]) => void 197 + value: T 198 + onChange: (value: T) => void 212 199 }) { 213 200 const t = useTheme() 214 201 return ( ··· 227 214 {description} 228 215 </Text> 229 216 )} 230 - <ToggleButton.Group label={title} values={values} onChange={onChange}> 217 + <SegmentedControl.Root 218 + type="radio" 219 + label={title} 220 + value={value} 221 + onChange={onChange}> 231 222 {items.map(item => ( 232 - <ToggleButton.Button 223 + <SegmentedControl.Item 233 224 key={item.name} 234 225 label={item.label} 235 - name={item.name}> 236 - <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText> 237 - </ToggleButton.Button> 226 + value={item.name}> 227 + <SegmentedControl.ItemText> 228 + {item.label} 229 + </SegmentedControl.ItemText> 230 + </SegmentedControl.Item> 238 231 ))} 239 - </ToggleButton.Group> 232 + </SegmentedControl.Root> 240 233 </SettingsList.Group> 241 234 </> 242 235 )
+1 -1
src/screens/Settings/LanguageSettings.tsx
··· 164 164 label={_(msg`Select content languages`)} 165 165 size="small" 166 166 color="secondary" 167 - variant="solid" 167 + shape="rectangular" 168 168 onPress={onPressContentLanguages} 169 169 style={[a.justify_start, web({maxWidth: 400})]}> 170 170 <ButtonIcon
+21 -15
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 29 29 import {Admonition} from '#/components/Admonition' 30 30 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31 31 import * as Dialog from '#/components/Dialog' 32 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 32 33 import * as TextField from '#/components/forms/TextField' 33 - import * as ToggleButton from '#/components/forms/ToggleButton' 34 34 import { 35 35 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 36 36 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, ··· 395 395 /> 396 396 </TextField.Root> 397 397 </View> 398 - <ToggleButton.Group 398 + <SegmentedControl.Root 399 399 label={_(msg`Choose domain verification method`)} 400 - values={[dnsPanel ? 'dns' : 'file']} 401 - onChange={values => setDNSPanel(values[0] === 'dns')}> 402 - <ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}> 403 - <ToggleButton.ButtonText> 400 + type="tabs" 401 + value={dnsPanel ? 'dns' : 'file'} 402 + onChange={values => setDNSPanel(values === 'dns')}> 403 + <SegmentedControl.Item value="dns" label={_(msg`DNS Panel`)}> 404 + <SegmentedControl.ItemText> 404 405 <Trans>DNS Panel</Trans> 405 - </ToggleButton.ButtonText> 406 - </ToggleButton.Button> 407 - <ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}> 408 - <ToggleButton.ButtonText> 406 + </SegmentedControl.ItemText> 407 + </SegmentedControl.Item> 408 + <SegmentedControl.Item value="file" label={_(msg`No DNS Panel`)}> 409 + <SegmentedControl.ItemText> 409 410 <Trans>No DNS Panel</Trans> 410 - </ToggleButton.ButtonText> 411 - </ToggleButton.Button> 412 - </ToggleButton.Group> 411 + </SegmentedControl.ItemText> 412 + </SegmentedControl.Item> 413 + </SegmentedControl.Root> 413 414 {dnsPanel ? ( 414 415 <> 415 416 <Text> ··· 500 501 value={currentAccount?.did ?? ''} 501 502 label={_(msg`Copy DID`)} 502 503 size="large" 503 - variant="solid" 504 + shape="rectangular" 504 505 color="secondary" 505 - style={[a.px_md, a.border, t.atoms.border_contrast_low]}> 506 + style={[ 507 + a.px_md, 508 + a.border, 509 + t.atoms.border_contrast_low, 510 + t.atoms.bg_contrast_25, 511 + ]}> 506 512 <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text> 507 513 <ButtonIcon icon={CopyIcon} /> 508 514 </CopyButton>
+1
src/screens/Settings/components/SettingsList.tsx
··· 194 194 * also so that we can calculate transforms. 195 195 */ 196 196 const iconSize = { 197 + '2xs': 8, 197 198 xs: 12, 198 199 sm: 16, 199 200 md: 20,
+6 -2
src/state/global-gesture-events/index.tsx
··· 1 1 import {createContext, useContext, useMemo, useRef, useState} from 'react' 2 - import {View} from 'react-native' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 3 import { 4 4 Gesture, 5 5 GestureDetector, ··· 29 29 30 30 export function GlobalGestureEventsProvider({ 31 31 children, 32 + style, 32 33 }: { 33 34 children: React.ReactNode 35 + style?: StyleProp<ViewStyle> 34 36 }) { 35 37 const refCount = useRef(0) 36 38 const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) ··· 73 75 return ( 74 76 <Context.Provider value={ctx}> 75 77 <GestureDetector gesture={gesture}> 76 - <View collapsable={false}>{children}</View> 78 + <View collapsable={false} style={style}> 79 + {children} 80 + </View> 77 81 </GestureDetector> 78 82 </Context.Provider> 79 83 )
+9 -1
src/state/queries/post-interaction-settings.ts
··· 4 4 import {preferencesQueryKey} from '#/state/queries/preferences' 5 5 import {useAgent} from '#/state/session' 6 6 7 - export function usePostInteractionSettingsMutation() { 7 + export function usePostInteractionSettingsMutation({ 8 + onError, 9 + onSettled, 10 + }: { 11 + onError?: (error: Error) => void 12 + onSettled?: () => void 13 + } = {}) { 8 14 const qc = useQueryClient() 9 15 const agent = useAgent() 10 16 return useMutation({ ··· 16 22 queryKey: preferencesQueryKey, 17 23 }) 18 24 }, 25 + onError, 26 + onSettled, 19 27 }) 20 28 }
+9
src/storage/hooks/threadgate-nudged.ts
··· 1 + import {device, useStorage} from '#/storage' 2 + 3 + export function useThreadgateNudged() { 4 + const [threadgateNudged = false, setThreadgateNudged] = useStorage(device, [ 5 + 'threadgateNudged', 6 + ]) 7 + 8 + return [threadgateNudged, setThreadgateNudged] as const 9 + }
+1
src/storage/schema.ts
··· 40 40 // deer 41 41 deerGateCache: string 42 42 activitySubscriptionsNudged?: boolean 43 + threadgateNudged?: boolean 43 44 44 45 /** 45 46 * Policy update overlays. New IDs are required for each new announcement.
+59 -49
src/view/com/auth/server-input/index.tsx src/components/dialogs/ServerInput.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {BSKY_SERVICE} from '#/lib/constants' 8 - import {logEvent} from '#/lib/statsig/statsig' 8 + import {logger} from '#/logger' 9 9 import * as persisted from '#/state/persisted' 10 10 import {useSession} from '#/state/session' 11 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 11 + import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' 12 12 import {Admonition} from '#/components/Admonition' 13 13 import {Button, ButtonText} from '#/components/Button' 14 14 import * as Dialog from '#/components/Dialog' 15 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 15 16 import * as TextField from '#/components/forms/TextField' 16 - import * as ToggleButton from '#/components/forms/ToggleButton' 17 17 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 18 import {InlineLinkText} from '#/components/Link' 19 - import {P, Text} from '#/components/Typography' 19 + import {Text} from '#/components/Typography' 20 + 21 + type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' 20 22 21 23 export function ServerInputDialog({ 22 24 control, ··· 29 31 const formRef = useRef<DialogInnerRef>(null) 30 32 31 33 // persist these options between dialog open/close 32 - const [fixedOption, setFixedOption] = useState(BSKY_SERVICE) 34 + const [fixedOption, setFixedOption] = 35 + useState<SegmentedControlOptions>(BSKY_SERVICE) 33 36 const [previousCustomAddress, setPreviousCustomAddress] = useState('') 34 37 35 38 const onClose = useCallback(() => { ··· 40 43 setPreviousCustomAddress(result) 41 44 } 42 45 } 43 - logEvent('signin:hostingProviderPressed', { 46 + logger.metric('signin:hostingProviderPressed', { 44 47 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 45 48 }) 46 49 }, [onSelect, fixedOption]) ··· 49 52 <Dialog.Outer 50 53 control={control} 51 54 onClose={onClose} 52 - nativeOptions={{minHeight: height / 2}}> 55 + nativeOptions={platform({ 56 + android: {minHeight: height / 2}, 57 + ios: {preventExpansion: true}, 58 + })}> 53 59 <Dialog.Handle /> 54 60 <DialogInner 55 61 formRef={formRef} ··· 70 76 initialCustomAddress, 71 77 }: { 72 78 formRef: React.Ref<DialogInnerRef> 73 - fixedOption: string 74 - setFixedOption: (opt: string) => void 79 + fixedOption: SegmentedControlOptions 80 + setFixedOption: (opt: SegmentedControlOptions) => void 75 81 initialCustomAddress: string 76 82 }) { 77 83 const control = Dialog.useDialogContext() ··· 124 130 return ( 125 131 <Dialog.ScrollableInner 126 132 accessibilityDescribedBy="dialog-description" 127 - accessibilityLabelledBy="dialog-title"> 133 + accessibilityLabelledBy="dialog-title" 134 + style={web({maxWidth: 500})}> 128 135 <View style={[a.relative, a.gap_md, a.w_full]}> 129 - <Text nativeID="dialog-title" style={[a.text_2xl, a.font_semi_bold]}> 136 + <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> 130 137 <Trans>Choose your account provider</Trans> 131 138 </Text> 132 - <ToggleButton.Group 133 - label="Preferences" 134 - values={[fixedOption]} 135 - onChange={values => setFixedOption(values[0])}> 136 - <ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}> 137 - <ToggleButton.ButtonText>{_(msg`Bluesky`)}</ToggleButton.ButtonText> 138 - </ToggleButton.Button> 139 - <ToggleButton.Button 139 + <SegmentedControl.Root 140 + type="tabs" 141 + label={_(msg`Account provider`)} 142 + value={fixedOption} 143 + onChange={setFixedOption}> 144 + <SegmentedControl.Item 145 + testID="bskyServiceSelectBtn" 146 + value={BSKY_SERVICE} 147 + label={_(msg`Bluesky`)}> 148 + <SegmentedControl.ItemText> 149 + {_(msg`Bluesky`)} 150 + </SegmentedControl.ItemText> 151 + </SegmentedControl.Item> 152 + <SegmentedControl.Item 140 153 testID="customSelectBtn" 141 - name="custom" 154 + value="custom" 142 155 label={_(msg`Custom`)}> 143 - <ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText> 144 - </ToggleButton.Button> 145 - </ToggleButton.Group> 156 + <SegmentedControl.ItemText> 157 + {_(msg`Custom`)} 158 + </SegmentedControl.ItemText> 159 + </SegmentedControl.Item> 160 + </SegmentedControl.Root> 146 161 147 162 {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( 148 - <Admonition type="tip"> 149 - <Trans> 150 - Bluesky is an open network where you can choose your own provider. 151 - If you're new here, we recommend sticking with the default Bluesky 152 - Social option. 153 - </Trans> 154 - </Admonition> 163 + <View role="tabpanel"> 164 + <Admonition type="tip"> 165 + <Trans> 166 + Bluesky is an open network where you can choose your own 167 + provider. If you're new here, we recommend sticking with the 168 + default Bluesky Social option. 169 + </Trans> 170 + </Admonition> 171 + </View> 155 172 )} 156 173 157 174 {fixedOption === 'custom' && ( 158 - <View 159 - style={[ 160 - a.border, 161 - t.atoms.border_contrast_low, 162 - a.rounded_sm, 163 - a.px_md, 164 - a.py_md, 165 - ]}> 175 + <View role="tabpanel"> 166 176 <TextField.LabelText nativeID="address-input-label"> 167 177 <Trans>Server address</Trans> 168 178 </TextField.LabelText> ··· 197 207 )} 198 208 199 209 <View style={[a.py_xs]}> 200 - <P 201 - style={[ 202 - t.atoms.text_contrast_medium, 203 - a.text_sm, 204 - a.leading_snug, 205 - a.flex_1, 206 - ]}> 210 + <Text 211 + style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}> 207 212 {isFirstTimeUser ? ( 208 213 <Trans> 209 214 If you're a developer, you can host your own server. ··· 219 224 to="https://atproto.com/guides/self-hosting"> 220 225 <Trans>Learn more.</Trans> 221 226 </InlineLinkText> 222 - </P> 227 + </Text> 223 228 </View> 224 229 225 230 <View style={gtMobile && [a.flex_row, a.justify_end]}> 226 231 <Button 227 232 testID="doneBtn" 228 - variant="outline" 233 + variant="solid" 229 234 color="primary" 230 - size="small" 235 + size={platform({ 236 + native: 'large', 237 + web: 'small', 238 + })} 231 239 onPress={() => control.close()} 232 240 label={_(msg`Done`)}> 233 - <ButtonText>{_(msg`Done`)}</ButtonText> 241 + <ButtonText> 242 + <Trans>Done</Trans> 243 + </ButtonText> 234 244 </Button> 235 245 </View> 236 246 </View>
+4 -9
src/view/com/composer/labels/LabelsBtn.tsx
··· 10 10 type SelfLabel, 11 11 } from '#/lib/moderation' 12 12 import {isWeb} from '#/platform/detection' 13 - import {atoms as a, native, useTheme, web} from '#/alf' 13 + import {atoms as a, useTheme, web} from '#/alf' 14 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 15 import * as Dialog from '#/components/Dialog' 16 16 import * as Toggle from '#/components/forms/Toggle' 17 17 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' 18 19 import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' 19 20 import {Text} from '#/components/Typography' 20 21 ··· 49 50 return ( 50 51 <> 51 52 <Button 52 - variant="solid" 53 53 color="secondary" 54 54 size="small" 55 55 testID="labelsBtn" ··· 60 60 label={_(msg`Content warnings`)} 61 61 accessibilityHint={_( 62 62 msg`Opens a dialog to add a content warning to your post`, 63 - )} 64 - style={[ 65 - native({ 66 - paddingHorizontal: 8, 67 - paddingVertical: 6, 68 - }), 69 - ]}> 63 + )}> 70 64 <ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} /> 71 65 <ButtonText numberOfLines={1}> 72 66 {labels.length > 0 ? ( ··· 75 69 <Trans>Labels</Trans> 76 70 )} 77 71 </ButtonText> 72 + <ButtonIcon icon={TinyChevronIcon} size="2xs" /> 78 73 </Button> 79 74 80 75 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+126 -25
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 + import {useEffect, useMemo, useState} from 'react' 1 2 import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' 2 3 import {type AnimatedStyle} from 'react-native-reanimated' 3 4 import {type AppBskyFeedPostgate} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 5 + import {msg, Trans} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' 7 + import deepEqual from 'lodash.isequal' 6 8 9 + import {isNetworkError} from '#/lib/strings/errors' 10 + import {logger} from '#/logger' 7 11 import {isNative} from '#/platform/detection' 8 - import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' 9 - import {native} from '#/alf' 12 + import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings' 13 + import {createPostgateRecord} from '#/state/queries/postgate/util' 14 + import {usePreferencesQuery} from '#/state/queries/preferences' 15 + import { 16 + type ThreadgateAllowUISetting, 17 + threadgateAllowUISettingToAllowRecordValue, 18 + threadgateRecordToAllowUISetting, 19 + } from '#/state/queries/threadgate' 10 20 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 21 import * as Dialog from '#/components/Dialog' 12 22 import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' 13 - import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 14 - import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 23 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' 24 + import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 25 + import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 26 + import * as Tooltip from '#/components/Tooltip' 27 + import {Text} from '#/components/Typography' 28 + import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' 15 29 16 30 export function ThreadgateBtn({ 17 31 postgate, ··· 29 43 }) { 30 44 const {_} = useLingui() 31 45 const control = Dialog.useDialogControl() 46 + const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() 47 + const [showTooltip, setShowTooltip] = useState(false) 48 + 49 + useEffect(() => { 50 + if (!threadgateNudged) { 51 + const timeout = setTimeout(() => { 52 + setShowTooltip(true) 53 + }, 1000) 54 + return () => clearTimeout(timeout) 55 + } 56 + }, [threadgateNudged]) 57 + 58 + const onDismissTooltip = (visible: boolean) => { 59 + if (visible) return 60 + setThreadgateNudged(true) 61 + setShowTooltip(false) 62 + } 63 + 64 + const {data: preferences} = usePreferencesQuery() 65 + const [persist, setPersist] = useState(false) 32 66 33 67 const onPress = () => { 34 68 if (isNative && Keyboard.isVisible()) { 35 69 Keyboard.dismiss() 36 70 } 37 71 72 + setShowTooltip(false) 73 + setThreadgateNudged(true) 74 + 38 75 control.open() 39 76 } 40 77 78 + const prefThreadgateAllowUISettings = threadgateRecordToAllowUISetting({ 79 + $type: 'app.bsky.feed.threadgate', 80 + post: '', 81 + createdAt: new Date().toISOString(), 82 + allow: preferences?.postInteractionSettings.threadgateAllowRules, 83 + }) 84 + const prefPostgate = createPostgateRecord({ 85 + post: '', 86 + embeddingRules: 87 + preferences?.postInteractionSettings?.postgateEmbeddingRules || [], 88 + }) 89 + 90 + const isDirty = useMemo(() => { 91 + const everybody = [{type: 'everybody'}] 92 + return ( 93 + !deepEqual( 94 + threadgateAllowUISettings, 95 + prefThreadgateAllowUISettings ?? everybody, 96 + ) || 97 + !deepEqual(postgate.embeddingRules, prefPostgate?.embeddingRules ?? []) 98 + ) 99 + }, [ 100 + prefThreadgateAllowUISettings, 101 + prefPostgate, 102 + threadgateAllowUISettings, 103 + postgate, 104 + ]) 105 + 106 + const {mutate: persistChanges, isPending: isSaving} = 107 + usePostInteractionSettingsMutation({ 108 + onError: err => { 109 + if (!isNetworkError(err)) { 110 + logger.error('Failed to persist threadgate settings', { 111 + safeMessage: err, 112 + }) 113 + } 114 + }, 115 + onSettled: () => { 116 + control.close(() => { 117 + setPersist(false) 118 + }) 119 + }, 120 + }) 121 + 41 122 const anyoneCanReply = 42 123 threadgateAllowUISettings.length === 1 && 43 124 threadgateAllowUISettings[0].type === 'everybody' ··· 50 131 51 132 return ( 52 133 <> 53 - <Button 54 - variant="solid" 55 - color="secondary" 56 - size="small" 57 - testID="openReplyGateButton" 58 - onPress={onPress} 59 - label={label} 60 - accessibilityHint={_( 61 - msg`Opens a dialog to choose who can reply to this thread`, 62 - )} 63 - style={[ 64 - native({ 65 - paddingHorizontal: 8, 66 - paddingVertical: 6, 67 - }), 68 - ]}> 69 - <ButtonIcon icon={anyoneCanInteract ? Earth : Group} /> 70 - <ButtonText numberOfLines={1}>{label}</ButtonText> 71 - </Button> 134 + <Tooltip.Outer 135 + visible={showTooltip} 136 + onVisibleChange={onDismissTooltip} 137 + position="top"> 138 + <Tooltip.Target> 139 + <Button 140 + color={showTooltip ? 'primary_subtle' : 'secondary'} 141 + size="small" 142 + testID="openReplyGateButton" 143 + onPress={onPress} 144 + label={label} 145 + accessibilityHint={_( 146 + msg`Opens a dialog to choose who can interact with this post`, 147 + )}> 148 + <ButtonIcon icon={anyoneCanInteract ? EarthIcon : GroupIcon} /> 149 + <ButtonText numberOfLines={1}>{label}</ButtonText> 150 + <ButtonIcon icon={TinyChevronIcon} size="2xs" /> 151 + </Button> 152 + </Tooltip.Target> 153 + <Tooltip.TextBubble> 154 + <Text> 155 + <Trans>Psst! You can edit who can interact with this post.</Trans> 156 + </Text> 157 + </Tooltip.TextBubble> 158 + </Tooltip.Outer> 159 + 72 160 <PostInteractionSettingsControlledDialog 73 161 control={control} 74 162 onSave={() => { 75 - control.close() 163 + if (persist) { 164 + persistChanges({ 165 + threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue( 166 + threadgateAllowUISettings, 167 + ), 168 + postgateEmbeddingRules: postgate.embeddingRules ?? [], 169 + }) 170 + } else { 171 + control.close() 172 + } 76 173 }} 174 + isSaving={isSaving} 77 175 postgate={postgate} 78 176 onChangePostgate={onChangePostgate} 79 177 threadgateAllowUISettings={threadgateAllowUISettings} 80 178 onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} 179 + isDirty={isDirty} 180 + persist={persist} 181 + onChangePersist={setPersist} 81 182 /> 82 183 </> 83 184 )
+5 -7
src/view/com/util/images/AutoSizedImage.tsx
··· 17 17 useHighQualityImages, 18 18 } from '#/state/preferences/high-quality-images' 19 19 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 20 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 20 + import {atoms as a, useTheme} from '#/alf' 21 21 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 22 22 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 23 23 import {Text} from '#/components/Typography' ··· 34 34 children: React.ReactNode 35 35 }) { 36 36 const t = useTheme() 37 - const {gtMobile} = useBreakpoints() 38 37 /** 39 38 * Computed as a % value to apply as `paddingTop`, this basically controls 40 39 * the height of the image. 41 40 */ 42 41 const outerAspectRatio = React.useMemo<DimensionValue>(() => { 43 - const ratio = 44 - isNative || !gtMobile 45 - ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box 46 - : Math.min(1 / aspectRatio, 1) // 1:1 bounding box 42 + const ratio = isNative 43 + ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box 44 + : Math.min(1 / aspectRatio, 1) // 1:1 bounding box 47 45 return `${ratio * 100}%` 48 - }, [aspectRatio, gtMobile, minMobileAspectRatio]) 46 + }, [aspectRatio, minMobileAspectRatio]) 49 47 50 48 return ( 51 49 <View style={[a.w_full]}>
+32 -16
src/view/screens/Storybook/Forms.tsx
··· 4 4 import {atoms as a} from '#/alf' 5 5 import {Button, ButtonText} from '#/components/Button' 6 6 import {DateField, LabelText} from '#/components/forms/DateField' 7 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 7 8 import * as TextField from '#/components/forms/TextField' 8 9 import * as Toggle from '#/components/forms/Toggle' 9 10 import * as ToggleButton from '#/components/forms/ToggleButton' ··· 15 16 const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) 16 17 const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) 17 18 const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) 19 + const [segmentedControlValue, setSegmentedControlValue] = React.useState< 20 + 'hide' | 'warn' | 'show' 21 + >('warn') 18 22 19 23 const [value, setValue] = React.useState('') 20 24 const [date, setDate] = React.useState('2001-01-01') ··· 155 159 </View> 156 160 </Toggle.Group> 157 161 162 + <Toggle.Item name="d" disabled value label="Click me"> 163 + <Toggle.Switch /> 164 + <Toggle.LabelText>Click me</Toggle.LabelText> 165 + </Toggle.Item> 166 + <Toggle.Item name="d" disabled value isInvalid label="Click me"> 167 + <Toggle.Switch /> 168 + <Toggle.LabelText>Click me</Toggle.LabelText> 169 + </Toggle.Item> 170 + 158 171 <Toggle.Group 159 172 label="Toggle" 160 173 type="checkbox" ··· 245 258 <ToggleButton.ButtonText>Show</ToggleButton.ButtonText> 246 259 </ToggleButton.Button> 247 260 </ToggleButton.Group> 261 + </View> 248 262 249 - <View> 250 - <ToggleButton.Group 251 - label="Preferences" 252 - values={toggleGroupDValues} 253 - onChange={setToggleGroupDValues}> 254 - <ToggleButton.Button name="hide" label="Hide"> 255 - <ToggleButton.ButtonText>Hide</ToggleButton.ButtonText> 256 - </ToggleButton.Button> 257 - <ToggleButton.Button name="warn" label="Warn"> 258 - <ToggleButton.ButtonText>Warn</ToggleButton.ButtonText> 259 - </ToggleButton.Button> 260 - <ToggleButton.Button name="show" label="Show"> 261 - <ToggleButton.ButtonText>Show</ToggleButton.ButtonText> 262 - </ToggleButton.Button> 263 - </ToggleButton.Group> 264 - </View> 263 + <View style={[a.gap_md, a.align_start, a.w_full]}> 264 + <H3>SegmentedControl</H3> 265 + 266 + <SegmentedControl.Root 267 + label="Preferences" 268 + type="radio" 269 + value={segmentedControlValue} 270 + onChange={setSegmentedControlValue}> 271 + <SegmentedControl.Item value="hide" label="Hide"> 272 + <SegmentedControl.ItemText>Hide</SegmentedControl.ItemText> 273 + </SegmentedControl.Item> 274 + <SegmentedControl.Item value="warn" label="Warn"> 275 + <SegmentedControl.ItemText>Warn</SegmentedControl.ItemText> 276 + </SegmentedControl.Item> 277 + <SegmentedControl.Item value="show" label="Show"> 278 + <SegmentedControl.ItemText>Show</SegmentedControl.ItemText> 279 + </SegmentedControl.Item> 280 + </SegmentedControl.Root> 265 281 </View> 266 282 </View> 267 283 )
+15 -12
src/view/shell/Composer.ios.tsx
··· 3 3 4 4 import {useDialogStateControlContext} from '#/state/dialogs' 5 5 import {useComposerState} from '#/state/shell/composer' 6 + import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' 6 7 import {atoms as a, useTheme} from '#/alf' 7 - import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 8 + import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' 8 9 9 10 export function Composer({}: {winHeight: number}) { 10 11 const {setFullyExpandedCount} = useDialogStateControlContext() ··· 33 34 animationType="slide" 34 35 onRequestClose={() => ref.current?.onPressCancel()}> 35 36 <View style={[t.atoms.bg, a.flex_1]}> 36 - <ComposePost 37 - cancelRef={ref} 38 - replyTo={state?.replyTo} 39 - onPost={state?.onPost} 40 - onPostSuccess={state?.onPostSuccess} 41 - quote={state?.quote} 42 - mention={state?.mention} 43 - text={state?.text} 44 - imageUris={state?.imageUris} 45 - videoUri={state?.videoUri} 46 - /> 37 + <TooltipSheetCompatProvider> 38 + <ComposePost 39 + cancelRef={ref} 40 + replyTo={state?.replyTo} 41 + onPost={state?.onPost} 42 + onPostSuccess={state?.onPostSuccess} 43 + quote={state?.quote} 44 + mention={state?.mention} 45 + text={state?.text} 46 + imageUris={state?.imageUris} 47 + videoUri={state?.videoUri} 48 + /> 49 + </TooltipSheetCompatProvider> 47 50 </View> 48 51 </Modal> 49 52 )
+4 -4
yarn.lock
··· 11348 11348 resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" 11349 11349 integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== 11350 11350 11351 - expo-image-crop-tool@^0.1.8: 11352 - version "0.1.8" 11353 - resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.8.tgz#3e9f34825cf5d7dad1ef2786615571b078ece4e7" 11354 - integrity sha512-UlS1zV7JewUzuZzVT9aA0vFD1+dt+pU60ILgt3ntQl4G9SeDJ9bB/+ylz9dzn6BjZecUQkGJmbCQ3H7jGZeZMA== 11351 + expo-image-crop-tool@^0.4.0: 11352 + version "0.4.0" 11353 + resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.4.0.tgz#c376b0695e8b2bf6b38fff5595ce30aaf9cddd64" 11354 + integrity sha512-2KZI016tb2i0yb0ZRMdH8h1I4YofD78fG/l6KrQTFzy4DtKaQlmJwU2VSJ8AYV5/nxusbHxgro7RQnr1BQ5lJg== 11355 11355 11356 11356 expo-image-loader@~6.0.0: 11357 11357 version "6.0.0"