Bluesky app fork with some witchin' additions 💫

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

+1
.eslintrc.js
··· 43 suggestedTextWrappers: { 44 Button: 'ButtonText', 45 'ToggleButton.Button': 'ToggleButton.ButtonText', 46 }, 47 }, 48 ],
··· 43 suggestedTextWrappers: { 44 Button: 'ButtonText', 45 'ToggleButton.Button': 'ToggleButton.ButtonText', 46 + 'SegmentedControl.Item': 'SegmentedControl.ItemText', 47 }, 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 val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 244 bottomSheet?.let { 245 val behavior = BottomSheetBehavior.from(it) 246 247 - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 248 249 if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { 250 behavior.state = BottomSheetBehavior.STATE_EXPANDED 251 } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { 252 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 253 } 254 }
··· 243 val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 244 bottomSheet?.let { 245 val behavior = BottomSheetBehavior.from(it) 246 + val currentState = behavior.state 247 248 + val oldRatio = behavior.halfExpandedRatio 249 + var newRatio = getHalfExpandedRatio(contentHeight) 250 + behavior.halfExpandedRatio = newRatio 251 252 if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { 253 behavior.state = BottomSheetBehavior.STATE_EXPANDED 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) { 257 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 258 } 259 }
+2 -2
modules/bottom-sheet/index.ts
··· 1 import {BottomSheet} from './src/BottomSheet' 2 import { 3 BottomSheetSnapPoint, 4 - BottomSheetState, 5 - BottomSheetViewProps, 6 } from './src/BottomSheet.types' 7 import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' 8 import {
··· 1 import {BottomSheet} from './src/BottomSheet' 2 import { 3 BottomSheetSnapPoint, 4 + type BottomSheetState, 5 + type BottomSheetViewProps, 6 } from './src/BottomSheet.types' 7 import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' 8 import {
+15 -3
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 112 onStateChange={this.onStateChange} 113 extraStyles={extraStyles} 114 onLayout={e => { 115 - const {height} = e.nativeEvent.layout 116 - this.setState({viewHeight: height}) 117 - this.updateLayout() 118 }} 119 /> 120 </Portal>
··· 112 onStateChange={this.onStateChange} 113 extraStyles={extraStyles} 114 onLayout={e => { 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 + } 130 }} 131 /> 132 </Portal>
+1 -1
package.json
··· 143 "expo-font": "~14.0.9", 144 "expo-haptics": "~15.0.7", 145 "expo-image": "~3.0.10", 146 - "expo-image-crop-tool": "^0.1.8", 147 "expo-image-manipulator": "~14.0.7", 148 "expo-image-picker": "~17.0.8", 149 "expo-intent-launcher": "~13.0.7",
··· 143 "expo-font": "~14.0.9", 144 "expo-haptics": "~15.0.7", 145 "expo-image": "~3.0.10", 146 + "expo-image-crop-tool": "^0.4.0", 147 "expo-image-manipulator": "~14.0.7", 148 "expo-image-picker": "~17.0.8", 149 "expo-intent-launcher": "~13.0.7",
+2 -1
src/alf/typography.tsx
··· 25 fontFamily: Alf['fonts']['family'] 26 } & Pick<Alf, 'flags'>, 27 ) { 28 - const s = flatten(styles) 29 // should always be defined on these components 30 s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale 31
··· 25 fontFamily: Alf['fonts']['family'] 26 } & Pick<Alf, 'flags'>, 27 ) { 28 + const s = flatten(styles) ?? {} 29 + 30 // should always be defined on these components 31 s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale 32
+1
src/components/AppLanguageDropdown.tsx
··· 48 })} 49 variant="ghost" 50 color="secondary" 51 style={[ 52 a.pr_xs, 53 a.pl_sm,
··· 48 })} 49 variant="ghost" 50 color="secondary" 51 + shape="rectangular" 52 style={[ 53 a.pr_xs, 54 a.pl_sm,
+94 -46
src/components/Button.tsx
··· 39 | 'primary_subtle' 40 | 'negative_subtle' 41 export type ButtonSize = 'tiny' | 'small' | 'large' 42 - export type ButtonShape = 'round' | 'square' | 'default' 43 export type VariantProps = { 44 /** 45 * The style variation of the button ··· 56 size?: ButtonSize 57 /** 58 * The shape of the button 59 */ 60 shape?: ButtonShape 61 } ··· 437 if (size === 'large') { 438 baseStyles.push(a.rounded_full, { 439 paddingVertical: 12, 440 paddingHorizontal: 25, 441 gap: 3, 442 }) 443 } else if (size === 'small') { 444 - baseStyles.push(a.rounded_full, { 445 paddingVertical: 8, 446 paddingHorizontal: 13, 447 gap: 3, 448 }) 449 } else if (size === 'tiny') { 450 - baseStyles.push(a.rounded_full, { 451 paddingVertical: 5, 452 paddingHorizontal: 9, 453 gap: 2, 454 }) 455 } ··· 503 variant, 504 color, 505 size, 506 disabled: disabled || false, 507 }), 508 - [state, variant, color, size, disabled], 509 ) 510 511 return ( ··· 746 position?: 'left' | 'right' 747 size?: SVGIconProps['size'] 748 }) { 749 - const {size: buttonSize} = useButtonContext() 750 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 - >) 765 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] 778 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'] 788 789 - return { 790 - iconSize, 791 - iconContainerSize, 792 - } 793 - }, [buttonSize, size]) 794 795 return ( 796 <View 797 style={[ 798 a.z_20, 799 { 800 - width: iconContainerSize, 801 height: iconContainerSize, 802 }, 803 ]}> 804 <View
··· 39 | 'primary_subtle' 40 | 'negative_subtle' 41 export type ButtonSize = 'tiny' | 'small' | 'large' 42 + export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default' 43 export type VariantProps = { 44 /** 45 * The style variation of the button ··· 56 size?: ButtonSize 57 /** 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. 64 */ 65 shape?: ButtonShape 66 } ··· 442 if (size === 'large') { 443 baseStyles.push(a.rounded_full, { 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, 465 paddingHorizontal: 25, 466 + borderRadius: 10, 467 gap: 3, 468 }) 469 } else if (size === 'small') { 470 + baseStyles.push({ 471 paddingVertical: 8, 472 paddingHorizontal: 13, 473 + borderRadius: 8, 474 gap: 3, 475 }) 476 } else if (size === 'tiny') { 477 + baseStyles.push({ 478 paddingVertical: 5, 479 paddingHorizontal: 9, 480 + borderRadius: 6, 481 gap: 2, 482 }) 483 } ··· 531 variant, 532 color, 533 size, 534 + shape, 535 disabled: disabled || false, 536 }), 537 + [state, variant, color, size, shape, disabled], 538 ) 539 540 return ( ··· 775 position?: 'left' | 'right' 776 size?: SVGIconProps['size'] 777 }) { 778 + const {size: buttonSize, shape: buttonShape} = useButtonContext() 779 const textStyles = useSharedButtonTextStyles() 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 + >) 795 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] 809 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'] 819 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]) 840 841 return ( 842 <View 843 style={[ 844 a.z_20, 845 { 846 + width: size === '2xs' ? 10 : iconContainerSize, 847 height: iconContainerSize, 848 + marginLeft: iconNegativeMargin, 849 + marginRight: iconNegativeMargin, 850 }, 851 ]}> 852 <View
+1 -1
src/components/Pills.tsx
··· 170 }, [size]) 171 172 return ( 173 - <View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}> 174 <Text style={[a.text_xs, a.leading_tight]}> 175 <Trans>Follows You</Trans> 176 </Text>
··· 170 }, [size]) 171 172 return ( 173 + <View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}> 174 <Text style={[a.text_xs, a.leading_tight]}> 175 <Trans>Follows You</Trans> 176 </Text>
-2
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
··· 7 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 import {atoms as a} from '#/alf' 10 - import {MediaInsetBorder} from '#/components/MediaInsetBorder' 11 import * as BandwidthEstimate from './bandwidth-estimate' 12 import {Controls} from './web-controls/VideoControls' 13 ··· 102 hasSubtitleTrack={hasSubtitleTrack} 103 /> 104 </div> 105 - <MediaInsetBorder /> 106 </View> 107 ) 108 }
··· 7 8 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 9 import {atoms as a} from '#/alf' 10 import * as BandwidthEstimate from './bandwidth-estimate' 11 import {Controls} from './web-controls/VideoControls' 12 ··· 101 hasSubtitleTrack={hasSubtitleTrack} 102 /> 103 </div> 104 </View> 105 ) 106 }
+9 -29
src/components/Post/Embed/VideoEmbed/index.tsx
··· 7 8 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 9 import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' 10 - import {atoms as a, useTheme} from '#/alf' 11 import {Button} from '#/components/Button' 12 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 13 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' ··· 16 17 interface Props { 18 embed: AppBskyEmbedVideo.View 19 - crop?: 'none' | 'square' | 'constrained' 20 } 21 22 - export function VideoEmbed({embed, crop}: Props) { 23 - const t = useTheme() 24 const [key, setKey] = useState(0) 25 26 const renderError = useCallback( ··· 40 } 41 42 let constrained: number | undefined 43 - let max: number | undefined 44 if (aspectRatio !== undefined) { 45 const ratio = 1 / 2 // max of 1:2 ratio in feeds 46 constrained = Math.max(aspectRatio, ratio) 47 - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 48 } 49 - const cropDisabled = crop === 'none' 50 51 const contents = ( 52 <ErrorBoundary renderError={renderError} key={key}> ··· 56 57 return ( 58 <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 - )} 81 </View> 82 ) 83 }
··· 7 8 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 9 import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' 10 + import {atoms as a} from '#/alf' 11 import {Button} from '#/components/Button' 12 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 13 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' ··· 16 17 interface Props { 18 embed: AppBskyEmbedVideo.View 19 } 20 21 + export function VideoEmbed({embed}: Props) { 22 const [key, setKey] = useState(0) 23 24 const renderError = useCallback( ··· 38 } 39 40 let constrained: number | undefined 41 if (aspectRatio !== undefined) { 42 const ratio = 1 / 2 // max of 1:2 ratio in feeds 43 constrained = Math.max(aspectRatio, ratio) 44 } 45 46 const contents = ( 47 <ErrorBoundary renderError={renderError} key={key}> ··· 51 52 return ( 53 <View style={[a.pt_xs]}> 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> 61 </View> 62 ) 63 }
+15 -33
src/components/Post/Embed/VideoEmbed/index.web.tsx
··· 17 import {atoms as a, useTheme} from '#/alf' 18 import {useIsWithinMessage} from '#/components/dms/MessageContext' 19 import {useFullscreen} from '#/components/hooks/useFullscreen' 20 import { 21 HLSUnsupportedError, 22 VideoEmbedInnerWeb, ··· 25 import {useActiveVideoWeb} from './ActiveVideoWebContext' 26 import * as VideoFallback from './VideoEmbedInner/VideoFallback' 27 28 - export function VideoEmbed({ 29 - embed, 30 - crop, 31 - }: { 32 - embed: AppBskyEmbedVideo.View 33 - crop?: 'none' | 'square' | 'constrained' 34 - }) { 35 const t = useTheme() 36 const ref = useRef<HTMLDivElement>(null) 37 const {active, setActive, sendPosition, currentActiveView} = ··· 76 } 77 78 let constrained: number | undefined 79 - let max: number | undefined 80 if (aspectRatio !== undefined) { 81 const ratio = 1 / 2 // max of 1:2 ratio in feeds 82 constrained = Math.max(aspectRatio, ratio) 83 - max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread 84 } 85 - const cropDisabled = crop === 'none' 86 87 const contents = ( 88 <div ··· 91 display: 'flex', 92 flex: 1, 93 cursor: 'default', 94 backgroundImage: `url(${embed.thumbnail})`, 95 - backgroundSize: 'cover', 96 }} 97 onClick={evt => evt.stopPropagation()}> 98 <ErrorBoundary renderError={renderError} key={key}> ··· 114 <ViewportObserver 115 sendPosition={sendPosition} 116 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 - )} 139 </ViewportObserver> 140 </View> 141 )
··· 17 import {atoms as a, useTheme} from '#/alf' 18 import {useIsWithinMessage} from '#/components/dms/MessageContext' 19 import {useFullscreen} from '#/components/hooks/useFullscreen' 20 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 21 import { 22 HLSUnsupportedError, 23 VideoEmbedInnerWeb, ··· 26 import {useActiveVideoWeb} from './ActiveVideoWebContext' 27 import * as VideoFallback from './VideoEmbedInner/VideoFallback' 28 29 + export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { 30 const t = useTheme() 31 const ref = useRef<HTMLDivElement>(null) 32 const {active, setActive, sendPosition, currentActiveView} = ··· 71 } 72 73 let constrained: number | undefined 74 if (aspectRatio !== undefined) { 75 const ratio = 1 / 2 // max of 1:2 ratio in feeds 76 constrained = Math.max(aspectRatio, ratio) 77 } 78 79 const contents = ( 80 <div ··· 83 display: 'flex', 84 flex: 1, 85 cursor: 'default', 86 + backgroundColor: t.palette.black, 87 backgroundImage: `url(${embed.thumbnail})`, 88 + backgroundSize: 'contain', 89 + backgroundPosition: 'center', 90 + backgroundRepeat: 'no-repeat', 91 }} 92 onClick={evt => evt.stopPropagation()}> 93 <ErrorBoundary renderError={renderError} key={key}> ··· 109 <ViewportObserver 110 sendPosition={sendPosition} 111 isAnyViewActive={currentActiveView !== null}> 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> 121 </ViewportObserver> 122 </View> 123 )
+1 -1
src/components/Post/Embed/index.tsx
··· 115 <ContentHider 116 modui={rest.moderation?.ui('contentMedia')} 117 activeStyle={[a.mt_sm]}> 118 - <VideoEmbed embed={embed.view} crop="constrained" /> 119 </ContentHider> 120 ) 121 }
··· 115 <ContentHider 116 modui={rest.moderation?.ui('contentMedia')} 117 activeStyle={[a.mt_sm]}> 118 + <VideoEmbed embed={embed.view} /> 119 </ContentHider> 120 ) 121 }
+3 -3
src/components/RichText.tsx
··· 1 import React from 'react' 2 - import {type TextStyle} from 'react-native' 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 5 import {toShortUrl} from '#/lib/strings/url-helpers' ··· 21 enableTags?: boolean 22 authorHandle?: string 23 onLinkPress?: LinkProps['onPress'] 24 - interactiveStyle?: TextStyle 25 emojiMultiplier?: number 26 shouldProxyLinks?: boolean 27 } ··· 55 56 if (!facets?.length) { 57 if (isOnlyEmoji(text)) { 58 - const flattenedStyle = flatten(style) 59 const fontSize = 60 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier 61 return (
··· 1 import React from 'react' 2 + import {type StyleProp, type TextStyle} from 'react-native' 3 import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' 4 5 import {toShortUrl} from '#/lib/strings/url-helpers' ··· 21 enableTags?: boolean 22 authorHandle?: string 23 onLinkPress?: LinkProps['onPress'] 24 + interactiveStyle?: StyleProp<TextStyle> 25 emojiMultiplier?: number 26 shouldProxyLinks?: boolean 27 } ··· 55 56 if (!facets?.length) { 57 if (isOnlyEmoji(text)) { 58 + const flattenedStyle = flatten(style) ?? {} 59 const fontSize = 60 (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier 61 return (
+1 -1
src/components/Select/index.tsx
··· 106 style={[a.flex_1, a.justify_between]} 107 color="secondary" 108 size="small" 109 - variant="solid"> 110 <>{children}</> 111 </Button> 112 )
··· 106 style={[a.flex_1, a.justify_between]} 107 color="secondary" 108 size="small" 109 + shape="rectangular"> 110 <>{children}</> 111 </Button> 112 )
+18 -8
src/components/Select/index.web.tsx
··· 1 - import {createContext, forwardRef, useContext, useMemo} from 'react' 2 import {View} from 'react-native' 3 import {Select as RadixSelect} from 'radix-ui' 4 ··· 96 style={flatten([ 97 a.flex, 98 a.relative, 99 - t.atoms.bg_contrast_25, 100 - a.rounded_sm, 101 a.w_full, 102 a.align_center, 103 a.gap_sm, ··· 106 a.px_md, 107 a.pointer, 108 { 109 maxWidth: 400, 110 outline: 0, 111 borderWidth: 2, 112 borderStyle: 'solid', 113 borderColor: focused 114 ? t.palette.primary_500 115 - : hovered 116 - ? t.palette.contrast_100 117 - : t.palette.contrast_25, 118 }, 119 ])}> 120 {children} ··· 140 ) 141 } 142 143 - export function Content<T>({items, renderItem}: ContentProps<T>) { 144 const t = useTheme() 145 const selectedValue = useContext(SelectedValueContext) 146 ··· 198 <ChevronUpIcon style={[t.atoms.text]} size="xs" /> 199 </RadixSelect.ScrollUpButton> 200 <RadixSelect.Viewport style={flatten([a.p_xs])}> 201 - {items.map((item, index) => renderItem(item, index, selectedValue))} 202 </RadixSelect.Viewport> 203 <RadixSelect.ScrollDownButton style={flatten(down)}> 204 <ChevronDownIcon style={[t.atoms.text]} size="xs" /> ··· 207 </RadixSelect.Content> 208 </RadixSelect.Portal> 209 ) 210 } 211 212 const ItemContext = createContext<{
··· 1 + import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react' 2 import {View} from 'react-native' 3 import {Select as RadixSelect} from 'radix-ui' 4 ··· 96 style={flatten([ 97 a.flex, 98 a.relative, 99 + t.atoms.bg_contrast_50, 100 a.w_full, 101 a.align_center, 102 a.gap_sm, ··· 105 a.px_md, 106 a.pointer, 107 { 108 + borderRadius: 10, 109 maxWidth: 400, 110 outline: 0, 111 borderWidth: 2, 112 borderStyle: 'solid', 113 borderColor: focused 114 ? t.palette.primary_500 115 + : t.palette.contrast_50, 116 }, 117 ])}> 118 {children} ··· 138 ) 139 } 140 141 + export function Content<T>({ 142 + items, 143 + renderItem, 144 + valueExtractor = defaultItemValueExtractor, 145 + }: ContentProps<T>) { 146 const t = useTheme() 147 const selectedValue = useContext(SelectedValueContext) 148 ··· 200 <ChevronUpIcon style={[t.atoms.text]} size="xs" /> 201 </RadixSelect.ScrollUpButton> 202 <RadixSelect.Viewport style={flatten([a.p_xs])}> 203 + {items.map((item, index) => ( 204 + <Fragment key={valueExtractor(item)}> 205 + {renderItem(item, index, selectedValue)} 206 + </Fragment> 207 + ))} 208 </RadixSelect.Viewport> 209 <RadixSelect.ScrollDownButton style={flatten(down)}> 210 <ChevronDownIcon style={[t.atoms.text]} size="xs" /> ··· 213 </RadixSelect.Content> 214 </RadixSelect.Portal> 215 ) 216 + } 217 + 218 + function defaultItemValueExtractor(item: any) { 219 + return item.value 220 } 221 222 const ItemContext = createContext<{
+65 -9
src/components/Tooltip/index.tsx
··· 12 import Animated, {Easing, ZoomIn} from 'react-native-reanimated' 13 import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 15 import {atoms as a, select, useTheme} from '#/alf' 16 import {useOnGesture} from '#/components/hooks/useOnGesture' 17 - import {Portal} from '#/components/Portal' 18 import { 19 ARROW_HALF_SIZE, 20 ARROW_SIZE, ··· 22 MIN_EDGE_SPACE, 23 } from '#/components/Tooltip/const' 24 import {Text} from '#/components/Typography' 25 26 /** 27 * These are native specific values, not shared with web ··· 120 121 export function Target({children}: {children: React.ReactNode}) { 122 const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) 123 const targetRef = useRef<View>(null) 124 125 useEffect(() => { 126 - if (!shouldMeasure) return 127 /* 128 * Once opened, measure the dimensions and position of the target 129 */ 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]) 136 137 return ( 138 - <View collapsable={false} ref={targetRef}> 139 {children} 140 </View> 141 ) ··· 150 }) { 151 const {position, visible, onVisibleChange} = useContext(TooltipContext) 152 const {targetMeasurements} = useContext(TargetContext) 153 const requestClose = useCallback(() => { 154 onVisibleChange(false) 155 }, [onVisibleChange]) 156 157 if (!visible || !targetMeasurements) return null 158 159 return ( 160 <Portal>
··· 12 import Animated, {Easing, ZoomIn} from 'react-native-reanimated' 13 import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 15 + import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 16 + import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 17 import {atoms as a, select, useTheme} from '#/alf' 18 import {useOnGesture} from '#/components/hooks/useOnGesture' 19 + import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' 20 import { 21 ARROW_HALF_SIZE, 22 ARROW_SIZE, ··· 24 MIN_EDGE_SPACE, 25 } from '#/components/Tooltip/const' 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' 54 55 /** 56 * These are native specific values, not shared with web ··· 149 150 export function Target({children}: {children: React.ReactNode}) { 151 const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) 152 + const [hasLayedOut, setHasLayedOut] = useState(false) 153 const targetRef = useRef<View>(null) 154 + const containerRef = useContext(TooltipProviderContext) 155 + const keyboardIsOpen = useIsKeyboardVisible() 156 157 useEffect(() => { 158 + if (!shouldMeasure || !hasLayedOut) return 159 /* 160 * Once opened, measure the dimensions and position of the target 161 */ 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 + ]) 186 187 return ( 188 + <View 189 + collapsable={false} 190 + ref={targetRef} 191 + onLayout={() => setHasLayedOut(true)}> 192 {children} 193 </View> 194 ) ··· 203 }) { 204 const {position, visible, onVisibleChange} = useContext(TooltipContext) 205 const {targetMeasurements} = useContext(TargetContext) 206 + const isWithinProvider = !!useContext(TooltipProviderContext) 207 const requestClose = useCallback(() => { 208 onVisibleChange(false) 209 }, [onVisibleChange]) 210 211 if (!visible || !targetMeasurements) return null 212 + 213 + const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal 214 215 return ( 216 <Portal>
+14 -8
src/components/Tooltip/index.web.tsx
··· 11 } from '#/components/Tooltip/const' 12 import {Text} from '#/components/Typography' 13 14 type TooltipContextType = { 15 position: 'top' | 'bottom' 16 onVisibleChange: (open: boolean) => void 17 } 18 19 - const TooltipContext = createContext<TooltipContextType>({ 20 position: 'bottom', 21 - onVisibleChange: () => {}, 22 }) 23 TooltipContext.displayName = 'TooltipContext' 24 ··· 33 visible: boolean 34 onVisibleChange: (visible: boolean) => void 35 }) { 36 - const ctx = useMemo( 37 - () => ({position, onVisibleChange}), 38 - [position, onVisibleChange], 39 - ) 40 return ( 41 <Popover.Root open={visible} onOpenChange={onVisibleChange}> 42 <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> ··· 60 label: string 61 }) { 62 const t = useTheme() 63 - const {position, onVisibleChange} = useContext(TooltipContext) 64 return ( 65 <Popover.Portal> 66 <Popover.Content ··· 69 side={position} 70 sideOffset={4} 71 collisionPadding={MIN_EDGE_SPACE} 72 - onInteractOutside={() => onVisibleChange(false)} 73 style={flatten([ 74 a.rounded_sm, 75 select(t.name, {
··· 11 } from '#/components/Tooltip/const' 12 import {Text} from '#/components/Typography' 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 + 20 type TooltipContextType = { 21 position: 'top' | 'bottom' 22 onVisibleChange: (open: boolean) => void 23 } 24 25 + const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({ 26 position: 'bottom', 27 }) 28 TooltipContext.displayName = 'TooltipContext' 29 ··· 38 visible: boolean 39 onVisibleChange: (visible: boolean) => void 40 }) { 41 + const ctx = useMemo(() => ({position}), [position]) 42 return ( 43 <Popover.Root open={visible} onOpenChange={onVisibleChange}> 44 <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> ··· 62 label: string 63 }) { 64 const t = useTheme() 65 + const {position} = useContext(TooltipContext) 66 return ( 67 <Popover.Portal> 68 <Popover.Content ··· 71 side={position} 72 sideOffset={4} 73 collisionPadding={MIN_EDGE_SPACE} 74 + onInteractOutside={evt => { 75 + if (evt.type === 'dismissableLayer.focusOutside') { 76 + evt.preventDefault() 77 + } 78 + }} 79 style={flatten([ 80 a.rounded_sm, 81 select(t.name, {
+43 -16
src/components/WhoCanReply.tsx
··· 1 - import {Fragment, useMemo} from 'react' 2 import { 3 Keyboard, 4 Platform, ··· 22 type ThreadgateAllowUISetting, 23 threadgateViewToAllowUISetting, 24 } from '#/state/queries/threadgate' 25 - import {atoms as a, useTheme, web} from '#/alf' 26 import {Button, ButtonText} from '#/components/Button' 27 import * as Dialog from '#/components/Dialog' 28 import {useDialogControl} from '#/components/Dialog' ··· 30 PostInteractionSettingsDialog, 31 usePrefetchPostInteractionSettings, 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' 36 import {InlineLinkText} from '#/components/Link' 37 import {Text} from '#/components/Typography' 38 import * as bsky from '#/types/bsky' 39 - import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 40 41 interface WhoCanReplyProps { 42 post: AppBskyFeedDefs.PostView ··· 69 postUri: post.uri, 70 rootPostUri: rootUri, 71 }) 72 73 const anyoneCanReply = 74 settings.length === 1 && settings[0].type === 'everybody' ··· 84 Keyboard.dismiss() 85 } 86 if (isThreadAuthor) { 87 - editDialogControl.open() 88 } else { 89 infoDialogControl.open() 90 } ··· 100 {...(isThreadAuthor 101 ? Platform.select({ 102 web: { 103 - onHoverIn: prefetchPostInteractionSettings, 104 }, 105 native: { 106 - onPressIn: prefetchPostInteractionSettings, 107 }, 108 }) 109 : {})} 110 hitSlop={HITSLOP_10}> 111 - {({hovered}) => ( 112 - <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> 113 <Icon 114 - color={t.palette.contrast_400} 115 width={16} 116 settings={settings} 117 /> ··· 119 style={[ 120 a.text_sm, 121 a.leading_tight, 122 - t.atoms.text_contrast_medium, 123 - hovered && a.underline, 124 ]}> 125 {description} 126 </Text> 127 128 {isThreadAuthor && ( 129 - <PencilLine width={12} fill={t.palette.primary_500} /> 130 )} 131 </View> 132 )} ··· 164 settings.length === 0 || 165 settings.every(setting => setting.type === 'everybody') 166 const isNobody = !!settings.find(gate => gate.type === 'nobody') 167 - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group 168 return <IconComponent fill={color} width={width} /> 169 } 170
··· 1 + import {Fragment, useMemo, useRef} from 'react' 2 import { 3 Keyboard, 4 Platform, ··· 22 type ThreadgateAllowUISetting, 23 threadgateViewToAllowUISetting, 24 } from '#/state/queries/threadgate' 25 + import {atoms as a, native, useTheme, web} from '#/alf' 26 import {Button, ButtonText} from '#/components/Button' 27 import * as Dialog from '#/components/Dialog' 28 import {useDialogControl} from '#/components/Dialog' ··· 30 PostInteractionSettingsDialog, 31 usePrefetchPostInteractionSettings, 32 } from '#/components/dialogs/PostInteractionSettingsDialog' 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' 37 import {InlineLinkText} from '#/components/Link' 38 import {Text} from '#/components/Typography' 39 import * as bsky from '#/types/bsky' 40 41 interface WhoCanReplyProps { 42 post: AppBskyFeedDefs.PostView ··· 69 postUri: post.uri, 70 rootPostUri: rootUri, 71 }) 72 + const prefetchPromise = useRef<Promise<void>>(Promise.resolve()) 73 + 74 + const prefetch = () => { 75 + prefetchPromise.current = prefetchPostInteractionSettings() 76 + } 77 78 const anyoneCanReply = 79 settings.length === 1 && settings[0].type === 'everybody' ··· 89 Keyboard.dismiss() 90 } 91 if (isThreadAuthor) { 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 + }) 100 } else { 101 infoDialogControl.open() 102 } ··· 112 {...(isThreadAuthor 113 ? Platform.select({ 114 web: { 115 + onHoverIn: prefetch, 116 }, 117 native: { 118 + onPressIn: prefetch, 119 }, 120 }) 121 : {})} 122 hitSlop={HITSLOP_10}> 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 + ]}> 132 <Icon 133 + color={ 134 + isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400 135 + } 136 width={16} 137 settings={settings} 138 /> ··· 140 style={[ 141 a.text_sm, 142 a.leading_tight, 143 + isThreadAuthor 144 + ? {color: t.palette.primary_500} 145 + : t.atoms.text_contrast_medium, 146 + (hovered || focused || pressed) && web(a.underline), 147 ]}> 148 {description} 149 </Text> 150 151 {isThreadAuthor && ( 152 + <TinyChevronDownIcon width={8} fill={t.palette.primary_500} /> 153 )} 154 </View> 155 )} ··· 187 settings.length === 0 || 188 settings.every(setting => setting.type === 'everybody') 189 const isNobody = !!settings.find(gate => gate.type === 'nobody') 190 + const IconComponent = isEverybody 191 + ? EarthIcon 192 + : isNobody 193 + ? CircleBanSignIcon 194 + : GroupIcon 195 return <IconComponent fill={color} width={width} /> 196 } 197
+16 -4
src/components/activity-notifications/SubscribeProfileButton.tsx
··· 1 - import {useCallback} from 'react' 2 import {type ModerationOpts} from '@atproto/api' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' ··· 27 const subscribeDialogControl = useDialogControl() 28 const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = 29 useActivitySubscriptionsNudged() 30 31 - const onDismissTooltip = () => { 32 setActivitySubscriptionsNudged(true) 33 } 34 ··· 56 return ( 57 <> 58 <Tooltip.Outer 59 - visible={!activitySubscriptionsNudged} 60 onVisibleChange={onDismissTooltip} 61 position="bottom"> 62 <Tooltip.Target> ··· 65 testID="dmBtn" 66 size="small" 67 color="secondary" 68 - variant="solid" 69 shape="round" 70 label={_(msg`Get notified when ${name} posts`)} 71 onPress={wrappedOnPress}>
··· 1 + import {useCallback, useEffect, useState} from 'react' 2 import {type ModerationOpts} from '@atproto/api' 3 import {msg, Trans} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' ··· 27 const subscribeDialogControl = useDialogControl() 28 const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = 29 useActivitySubscriptionsNudged() 30 + const [showTooltip, setShowTooltip] = useState(false) 31 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) 45 setActivitySubscriptionsNudged(true) 46 } 47 ··· 69 return ( 70 <> 71 <Tooltip.Outer 72 + visible={showTooltip} 73 onVisibleChange={onDismissTooltip} 74 position="bottom"> 75 <Tooltip.Target> ··· 78 testID="dmBtn" 79 size="small" 80 color="secondary" 81 shape="round" 82 label={_(msg`Get notified when ${name} posts`)} 83 onPress={wrappedOnPress}>
+18 -17
src/components/dialogs/Embed.tsx
··· 10 import {atoms as a, useTheme} from '#/alf' 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 import * as Dialog from '#/components/Dialog' 13 import * as TextField from '#/components/forms/TextField' 14 - import * as ToggleButton from '#/components/forms/ToggleButton' 15 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 16 import { 17 ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, ··· 150 <Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}> 151 <Trans>Color theme</Trans> 152 </Text> 153 - <ToggleButton.Group 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> 159 <Trans>System</Trans> 160 - </ToggleButton.ButtonText> 161 - </ToggleButton.Button> 162 - <ToggleButton.Button name="light" label={_(msg`Light`)}> 163 - <ToggleButton.ButtonText> 164 <Trans>Light</Trans> 165 - </ToggleButton.ButtonText> 166 - </ToggleButton.Button> 167 - <ToggleButton.Button name="dark" label={_(msg`Dark`)}> 168 - <ToggleButton.ButtonText> 169 <Trans>Dark</Trans> 170 - </ToggleButton.ButtonText> 171 - </ToggleButton.Button> 172 - </ToggleButton.Group> 173 </View> 174 )} 175 </View>
··· 10 import {atoms as a, useTheme} from '#/alf' 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 import * as Dialog from '#/components/Dialog' 13 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 14 import * as TextField from '#/components/forms/TextField' 15 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 16 import { 17 ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, ··· 150 <Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}> 151 <Trans>Color theme</Trans> 152 </Text> 153 + <SegmentedControl.Root 154 label={_(msg`Color mode`)} 155 + type="radio" 156 + value={colorMode} 157 + onChange={setColorMode}> 158 + <SegmentedControl.Item value="system" label={_(msg`System`)}> 159 + <SegmentedControl.ItemText> 160 <Trans>System</Trans> 161 + </SegmentedControl.ItemText> 162 + </SegmentedControl.Item> 163 + <SegmentedControl.Item value="light" label={_(msg`Light`)}> 164 + <SegmentedControl.ItemText> 165 <Trans>Light</Trans> 166 + </SegmentedControl.ItemText> 167 + </SegmentedControl.Item> 168 + <SegmentedControl.Item value="dark" label={_(msg`Dark`)}> 169 + <SegmentedControl.ItemText> 170 <Trans>Dark</Trans> 171 + </SegmentedControl.ItemText> 172 + </SegmentedControl.Item> 173 + </SegmentedControl.Root> 174 </View> 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' 3 import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPostgate, 6 AtUri, 7 } from '@atproto/api' 8 - import {msg, Trans} from '@lingui/macro' 9 import {useLingui} from '@lingui/react' 10 import {useQueryClient} from '@tanstack/react-query' 11 - import isEqual from 'lodash.isequal' 12 13 import {logger} from '#/logger' 14 import {STALE} from '#/state/queries' 15 import {useMyListsQuery} from '#/state/queries/my-lists' 16 import {useGetPost} from '#/state/queries/post' ··· 37 } from '#/state/queries/usePostThread' 38 import {useAgent, useSession} from '#/state/session' 39 import * as Toast from '#/view/com/util/Toast' 40 - import {atoms as a, useTheme} from '#/alf' 41 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 42 import * as Dialog from '#/components/Dialog' 43 - import {Divider} from '#/components/Divider' 44 import * as Toggle from '#/components/forms/Toggle' 45 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 46 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 47 import {Loader} from '#/components/Loader' 48 import {Text} from '#/components/Typography' 49 ··· 52 onSave: () => void 53 isSaving?: boolean 54 55 postgate: AppBskyFeedPostgate.Record 56 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 57 ··· 61 replySettingsDisabled?: boolean 62 } 63 64 export function PostInteractionSettingsControlledDialog({ 65 control, 66 ...rest 67 }: PostInteractionSettingsFormProps & { 68 control: Dialog.DialogControlProps 69 }) { 70 - const t = useTheme() 71 - const {_} = useLingui() 72 - 73 return ( 74 - <Dialog.Outer control={control}> 75 <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> 100 </Dialog.Outer> 101 ) 102 } 103 104 - export function Header() { 105 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> 115 ) 116 } 117 ··· 134 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 135 } 136 137 export function PostInteractionSettingsDialog( 138 props: PostInteractionSettingsDialogProps, 139 ) { 140 const postThreadContext = usePostThreadContext() 141 return ( 142 - <Dialog.Outer control={props.control}> 143 <Dialog.Handle /> 144 <PostThreadContextProvider context={postThreadContext}> 145 <PostInteractionSettingsDialogControlledInner {...props} /> ··· 153 ) { 154 const {_} = useLingui() 155 const {currentAccount} = useSession() 156 - const [isSaving, setIsSaving] = React.useState(false) 157 158 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 159 useThreadgateViewQuery({postUri: props.rootPostUri}) ··· 165 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 166 167 const [editedPostgate, setEditedPostgate] = 168 - React.useState<AppBskyFeedPostgate.Record>() 169 const [editedAllowUISettings, setEditedAllowUISettings] = 170 - React.useState<ThreadgateAllowUISetting[]>() 171 172 const isLoading = isLoadingThreadgate || isLoadingPostgate 173 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 174 - const isThreadgateOwnedByViewer = React.useMemo(() => { 175 return currentAccount?.did === new AtUri(props.rootPostUri).host 176 }, [props.rootPostUri, currentAccount?.did]) 177 178 - const postgateValue = React.useMemo(() => { 179 return ( 180 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 181 ) 182 }, [postgate, editedPostgate, props.postUri]) 183 - const allowUIValue = React.useMemo(() => { 184 return ( 185 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 186 ) 187 }, [threadgateView, editedAllowUISettings]) 188 189 - const onSave = React.useCallback(async () => { 190 if (!editedPostgate && !editedAllowUISettings) { 191 props.control.close() 192 return ··· 248 return ( 249 <Dialog.ScrollableInner 250 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 - ) : ( 260 <PostInteractionSettingsForm 261 replySettingsDisabled={!isThreadgateOwnedByViewer} 262 isSaving={isSaving} ··· 266 threadgateAllowUISettings={allowUIValue} 267 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 268 /> 269 - )} 270 - </View> 271 </Dialog.ScrollableInner> 272 ) 273 } ··· 281 threadgateAllowUISettings, 282 onChangeThreadgateAllowUISettings, 283 replySettingsDisabled, 284 }: PostInteractionSettingsFormProps) { 285 const t = useTheme() 286 const {_} = useLingui() 287 - const {data: lists} = useMyListsQuery('curate') 288 - const [quotesEnabled, setQuotesEnabled] = React.useState( 289 !( 290 postgate.embeddingRules && 291 postgate.embeddingRules.find( ··· 294 ), 295 ) 296 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( 318 (enabled: boolean) => { 319 setQuotesEnabled(enabled) 320 onChangePostgate( ··· 330 const noOneCanReply = !!threadgateAllowUISettings.find( 331 v => v.type === 'nobody', 332 ) 333 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> 342 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> 360 361 - <Divider /> 362 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 - )} 387 388 <View 389 style={[ 390 a.gap_sm, 391 - { 392 - opacity: replySettingsDisabled ? 0.3 : 1, 393 - }, 394 ]}> 395 - <Text style={[a.font_semi_bold, a.text_lg]}> 396 - <Trans>Reply settings</Trans> 397 </Text> 398 399 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 400 - <Trans>Allow replies from:</Trans> 401 - </Text> 402 403 <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 - /> 424 </View> 425 426 - {!noOneCanReply && ( 427 - <> 428 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 429 - <Trans>Or combine these options:</Trans> 430 - </Text> 431 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} 462 /> 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> 485 </View> 486 </View> 487 488 <Button 489 disabled={!canSave || isSaving} 490 label={_(msg`Save`)} 491 onPress={onSave} 492 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" />} 498 </Button> 499 </View> 500 ) 501 } 502 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() 517 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> 556 ) 557 } 558 ··· 567 const agent = useAgent() 568 const getPost = useGetPost() 569 570 - return React.useCallback(async () => { 571 try { 572 await Promise.all([ 573 queryClient.prefetchQuery({
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {LayoutAnimation, Text as NestedText, View} from 'react-native' 3 import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPostgate, 6 AtUri, 7 } from '@atproto/api' 8 + import {msg, Plural, Trans} from '@lingui/macro' 9 import {useLingui} from '@lingui/react' 10 import {useQueryClient} from '@tanstack/react-query' 11 12 + import {useHaptics} from '#/lib/haptics' 13 import {logger} from '#/logger' 14 + import {isIOS} from '#/platform/detection' 15 import {STALE} from '#/state/queries' 16 import {useMyListsQuery} from '#/state/queries/my-lists' 17 import {useGetPost} from '#/state/queries/post' ··· 38 } from '#/state/queries/usePostThread' 39 import {useAgent, useSession} from '#/state/session' 40 import * as Toast from '#/view/com/util/Toast' 41 + import {UserAvatar} from '#/view/com/util/UserAvatar' 42 + import {atoms as a, useTheme, web} from '#/alf' 43 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 44 import * as Dialog from '#/components/Dialog' 45 import * as Toggle from '#/components/forms/Toggle' 46 + import { 47 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 48 + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 49 + } from '#/components/icons/Chevron' 50 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 51 + import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 52 import {Loader} from '#/components/Loader' 53 import {Text} from '#/components/Typography' 54 ··· 57 onSave: () => void 58 isSaving?: boolean 59 60 + isDirty?: boolean 61 + persist?: boolean 62 + onChangePersist?: (v: boolean) => void 63 + 64 postgate: AppBskyFeedPostgate.Record 65 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 66 ··· 70 replySettingsDisabled?: boolean 71 } 72 73 + /** 74 + * Threadgate settings dialog. Used in the composer. 75 + */ 76 export function PostInteractionSettingsControlledDialog({ 77 control, 78 ...rest 79 }: PostInteractionSettingsFormProps & { 80 control: Dialog.DialogControlProps 81 }) { 82 return ( 83 + <Dialog.Outer 84 + control={control} 85 + nativeOptions={{ 86 + preventExpansion: true, 87 + preventDismiss: rest.isDirty && rest.persist, 88 + }}> 89 <Dialog.Handle /> 90 + <DialogInner {...rest} /> 91 </Dialog.Outer> 92 ) 93 } 94 95 + function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) { 96 + const {_} = useLingui() 97 + 98 return ( 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> 106 ) 107 } 108 ··· 125 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 126 } 127 128 + /** 129 + * Threadgate settings dialog. Used in the thread. 130 + */ 131 export function PostInteractionSettingsDialog( 132 props: PostInteractionSettingsDialogProps, 133 ) { 134 const postThreadContext = usePostThreadContext() 135 return ( 136 + <Dialog.Outer 137 + control={props.control} 138 + nativeOptions={{preventExpansion: true}}> 139 <Dialog.Handle /> 140 <PostThreadContextProvider context={postThreadContext}> 141 <PostInteractionSettingsDialogControlledInner {...props} /> ··· 149 ) { 150 const {_} = useLingui() 151 const {currentAccount} = useSession() 152 + const [isSaving, setIsSaving] = useState(false) 153 154 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 155 useThreadgateViewQuery({postUri: props.rootPostUri}) ··· 161 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 162 163 const [editedPostgate, setEditedPostgate] = 164 + useState<AppBskyFeedPostgate.Record>() 165 const [editedAllowUISettings, setEditedAllowUISettings] = 166 + useState<ThreadgateAllowUISetting[]>() 167 168 const isLoading = isLoadingThreadgate || isLoadingPostgate 169 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 170 + const isThreadgateOwnedByViewer = useMemo(() => { 171 return currentAccount?.did === new AtUri(props.rootPostUri).host 172 }, [props.rootPostUri, currentAccount?.did]) 173 174 + const postgateValue = useMemo(() => { 175 return ( 176 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 177 ) 178 }, [postgate, editedPostgate, props.postUri]) 179 + const allowUIValue = useMemo(() => { 180 return ( 181 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 182 ) 183 }, [threadgateView, editedAllowUISettings]) 184 185 + const onSave = useCallback(async () => { 186 if (!editedPostgate && !editedAllowUISettings) { 187 props.control.close() 188 return ··· 244 return ( 245 <Dialog.ScrollableInner 246 label={_(msg`Edit post interaction settings`)} 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 /> 265 <PostInteractionSettingsForm 266 replySettingsDisabled={!isThreadgateOwnedByViewer} 267 isSaving={isSaving} ··· 271 threadgateAllowUISettings={allowUIValue} 272 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 273 /> 274 + </> 275 + )} 276 + <Dialog.Close /> 277 </Dialog.ScrollableInner> 278 ) 279 } ··· 287 threadgateAllowUISettings, 288 onChangeThreadgateAllowUISettings, 289 replySettingsDisabled, 290 + isDirty, 291 + persist, 292 + onChangePersist, 293 }: PostInteractionSettingsFormProps) { 294 const t = useTheme() 295 const {_} = useLingui() 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( 304 !( 305 postgate.embeddingRules && 306 postgate.embeddingRules.find( ··· 309 ), 310 ) 311 312 + const onChangeQuotesEnabled = useCallback( 313 (enabled: boolean) => { 314 setQuotesEnabled(enabled) 315 onChangePostgate( ··· 325 const noOneCanReply = !!threadgateAllowUISettings.find( 326 v => v.type === 'nobody', 327 ) 328 + const everyoneCanReply = !!threadgateAllowUISettings.find( 329 + v => v.type === 'everybody', 330 + ) 331 + const numberOfListsSelected = threadgateAllowUISettings.filter( 332 + v => v.type === 'list', 333 + ).length 334 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]) 361 362 + const toggleGroupOnChange = (values: string[]) => { 363 + const settings: ThreadgateAllowUISetting[] = [] 364 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 + } 377 378 + onChangeThreadgateAllowUISettings(settings) 379 + } 380 381 + return ( 382 + <View style={[a.flex_1, a.gap_lg]}> 383 + <View style={[a.gap_lg]}> 384 + {replySettingsDisabled && ( 385 <View 386 style={[ 387 + a.px_md, 388 + a.py_sm, 389 + a.rounded_sm, 390 + a.flex_row, 391 + a.align_center, 392 a.gap_sm, 393 + t.atoms.bg_contrast_25, 394 ]}> 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> 401 </Text> 402 + </View> 403 + )} 404 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> 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 + }}> 427 <View style={[a.flex_row, a.gap_sm]}> 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> 456 </View> 457 + </Toggle.Group> 458 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> 509 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} 549 /> 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> 601 </View> 602 </View> 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 + 647 <Button 648 disabled={!canSave || isSaving} 649 label={_(msg`Save`)} 650 onPress={onSave} 651 color="primary" 652 + size="large"> 653 + <ButtonText> 654 + <Trans>Save</Trans> 655 + </ButtonText> 656 + {isSaving && <ButtonIcon icon={Loader} />} 657 </Button> 658 </View> 659 ) 660 } 661 662 + function Header() { 663 return ( 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> 669 ) 670 } 671 ··· 680 const agent = useAgent() 681 const getPost = useGetPost() 682 683 + return useCallback(async () => { 684 try { 685 await Promise.all([ 686 queryClient.prefetchQuery({
+1 -1
src/components/forms/HostingProvider.tsx
··· 4 import {useLingui} from '@lingui/react' 5 6 import {toNiceDomain} from '#/lib/strings/url-helpers' 7 - import {ServerInputDialog} from '#/view/com/auth/server-input' 8 import {atoms as a, tokens, useTheme} from '#/alf' 9 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 import {useDialogControl} from '#/components/Dialog' 11 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 12 import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' 13 import {Text} from '#/components/Typography'
··· 4 import {useLingui} from '@lingui/react' 5 6 import {toNiceDomain} from '#/lib/strings/url-helpers' 7 import {atoms as a, tokens, useTheme} from '#/alf' 8 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 import {useDialogControl} from '#/components/Dialog' 10 + import {ServerInputDialog} from '#/components/dialogs/ServerInput' 11 import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 12 import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil' 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' 4 5 import {HITSLOP_10} from '#/lib/constants' 6 import {isNative} from '#/platform/detection' 7 import { 8 atoms as a, 9 native, 10 type TextStyleProp, 11 useTheme, 12 type ViewStyleProp, ··· 15 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 16 import {Text} from '#/components/Typography' 17 18 export type ItemState = { 19 name: string 20 selected: boolean ··· 25 focused: boolean 26 } 27 28 - const ItemContext = React.createContext<ItemState>({ 29 name: '', 30 selected: false, 31 disabled: false, ··· 36 }) 37 ItemContext.displayName = 'ToggleItemContext' 38 39 - const GroupContext = React.createContext<{ 40 values: string[] 41 disabled: boolean 42 type: 'radio' | 'checkbox' ··· 70 onChange?: (selected: boolean) => void 71 isInvalid?: boolean 72 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 73 } 74 75 export function useItemContext() { 76 - return React.useContext(ItemContext) 77 } 78 79 export function Group({ ··· 88 }: GroupProps) { 89 const groupRole = type === 'radio' ? 'radiogroup' : undefined 90 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 91 - const [maxReached, setMaxReached] = React.useState(false) 92 93 - const setFieldValue = React.useCallback< 94 (props: {name: string; value: boolean}) => void 95 >( 96 ({name, value}) => { ··· 105 [type, onChange, values], 106 ) 107 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]) 125 126 - const context = React.useMemo( 127 () => ({ 128 values, 129 type, ··· 170 disabled: groupDisabled, 171 setFieldValue, 172 maxSelectionsReached, 173 - } = React.useContext(GroupContext) 174 const { 175 state: hovered, 176 onIn: onHoverIn, ··· 182 onOut: onPressOut, 183 } = useInteractionState() 184 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 185 186 const role = groupType === 'radio' ? 'radio' : type 187 const selected = selectedValues.includes(name) || !!value 188 const disabled = 189 groupDisabled || itemDisabled || (!selected && maxSelectionsReached) 190 191 - const onPress = React.useCallback(() => { 192 const next = !selected 193 setFieldValue({name, value: next}) 194 onChange?.(next) 195 - }, [name, selected, onChange, setFieldValue]) 196 197 - const state = React.useMemo( 198 () => ({ 199 name, 200 selected, ··· 250 style={[ 251 a.font_semi_bold, 252 a.leading_tight, 253 { 254 - userSelect: 'none', 255 color: disabled 256 ? t.atoms.text_contrast_low.color 257 : t.atoms.text_contrast_high.color, ··· 287 288 if (selected) { 289 base.push({ 290 - backgroundColor: t.palette.primary_25, 291 borderColor: t.palette.primary_500, 292 }) 293 294 if (hovered) { 295 baseHover.push({ 296 - backgroundColor: t.palette.primary_100, 297 - borderColor: t.palette.primary_600, 298 }) 299 } 300 } else { 301 if (hovered) { 302 baseHover.push({ 303 backgroundColor: t.palette.contrast_50, 304 - borderColor: t.palette.contrast_500, 305 }) 306 } 307 } ··· 318 borderColor: t.palette.negative_600, 319 }) 320 } 321 } 322 323 if (disabled) { ··· 325 backgroundColor: t.palette.contrast_100, 326 borderColor: t.palette.contrast_400, 327 }) 328 } 329 330 return { ··· 350 style={[ 351 a.justify_center, 352 a.align_center, 353 - a.rounded_xs, 354 t.atoms.border_contrast_high, 355 { 356 borderWidth: 1, 357 height: 24, 358 width: 24, 359 }, 360 baseStyles, 361 hovered ? baseHoverStyles : {}, 362 ]}> 363 - {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} 364 </View> 365 ) 366 } 367 368 export function Switch() { 369 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 - }) 380 return ( 381 <View 382 style={[ 383 a.relative, 384 a.rounded_full, 385 t.atoms.bg, 386 - t.atoms.border_contrast_high, 387 { 388 - borderWidth: 1, 389 - height: 24, 390 - width: 36, 391 padding: 3, 392 }, 393 baseStyles, 394 hovered ? baseHoverStyles : {}, 395 ]}> 396 <Animated.View 397 - layout={LinearTransition.duration(100)} 398 style={[ 399 a.rounded_full, 400 { 401 - height: 16, 402 - width: 16, 403 }, 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 - }, 413 indicatorStyles, 414 ]} 415 /> ··· 420 export function Radio() { 421 const t = useTheme() 422 const {selected, hovered, focused, disabled, isInvalid} = 423 - React.useContext(ItemContext) 424 const {baseStyles, baseHoverStyles, indicatorStyles} = 425 createSharedToggleStyles({ 426 theme: t, ··· 437 a.align_center, 438 a.rounded_full, 439 t.atoms.border_contrast_high, 440 { 441 borderWidth: 1, 442 - height: 24, 443 - width: 24, 444 }, 445 baseStyles, 446 hovered ? baseHoverStyles : {}, 447 ]}> 448 - {selected ? ( 449 <View 450 style={[ 451 a.absolute, 452 a.rounded_full, 453 - {height: 16, width: 16}, 454 - selected 455 - ? { 456 - backgroundColor: t.palette.primary_500, 457 - } 458 - : {}, 459 indicatorStyles, 460 ]} 461 /> 462 - ) : null} 463 </View> 464 ) 465 }
··· 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' 10 11 import {HITSLOP_10} from '#/lib/constants' 12 + import {useHaptics} from '#/lib/haptics' 13 import {isNative} from '#/platform/detection' 14 import { 15 atoms as a, 16 native, 17 + platform, 18 type TextStyleProp, 19 useTheme, 20 type ViewStyleProp, ··· 23 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 24 import {Text} from '#/components/Typography' 25 26 + export * from './Panel' 27 + 28 export type ItemState = { 29 name: string 30 selected: boolean ··· 35 focused: boolean 36 } 37 38 + const ItemContext = createContext<ItemState>({ 39 name: '', 40 selected: false, 41 disabled: false, ··· 46 }) 47 ItemContext.displayName = 'ToggleItemContext' 48 49 + const GroupContext = createContext<{ 50 values: string[] 51 disabled: boolean 52 type: 'radio' | 'checkbox' ··· 80 onChange?: (selected: boolean) => void 81 isInvalid?: boolean 82 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 83 + hitSlop?: PressableProps['hitSlop'] 84 } 85 86 export function useItemContext() { 87 + return useContext(ItemContext) 88 } 89 90 export function Group({ ··· 99 }: GroupProps) { 100 const groupRole = type === 'radio' ? 'radiogroup' : undefined 101 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 102 103 + const setFieldValue = useCallback< 104 (props: {name: string; value: boolean}) => void 105 >( 106 ({name, value}) => { ··· 115 [type, onChange, values], 116 ) 117 118 + const maxReached = !!( 119 + type === 'checkbox' && 120 + maxSelections && 121 + values.length >= maxSelections 122 + ) 123 124 + const context = useMemo( 125 () => ({ 126 values, 127 type, ··· 168 disabled: groupDisabled, 169 setFieldValue, 170 maxSelectionsReached, 171 + } = useContext(GroupContext) 172 const { 173 state: hovered, 174 onIn: onHoverIn, ··· 180 onOut: onPressOut, 181 } = useInteractionState() 182 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 183 + const playHaptic = useHaptics() 184 185 const role = groupType === 'radio' ? 'radio' : type 186 const selected = selectedValues.includes(name) || !!value 187 const disabled = 188 groupDisabled || itemDisabled || (!selected && maxSelectionsReached) 189 190 + const onPress = useCallback(() => { 191 + playHaptic('Light') 192 const next = !selected 193 setFieldValue({name, value: next}) 194 onChange?.(next) 195 + }, [playHaptic, name, selected, onChange, setFieldValue]) 196 197 + const state = useMemo( 198 () => ({ 199 name, 200 selected, ··· 250 style={[ 251 a.font_semi_bold, 252 a.leading_tight, 253 + a.user_select_none, 254 { 255 color: disabled 256 ? t.atoms.text_contrast_low.color 257 : t.atoms.text_contrast_high.color, ··· 287 288 if (selected) { 289 base.push({ 290 + backgroundColor: t.palette.primary_500, 291 borderColor: t.palette.primary_500, 292 }) 293 294 if (hovered) { 295 baseHover.push({ 296 + backgroundColor: t.palette.primary_400, 297 + borderColor: t.palette.primary_400, 298 }) 299 } 300 } else { 301 + base.push({ 302 + backgroundColor: t.palette.contrast_25, 303 + borderColor: t.palette.contrast_100, 304 + }) 305 + 306 if (hovered) { 307 baseHover.push({ 308 backgroundColor: t.palette.contrast_50, 309 + borderColor: t.palette.contrast_200, 310 }) 311 } 312 } ··· 323 borderColor: t.palette.negative_600, 324 }) 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 + } 340 } 341 342 if (disabled) { ··· 344 backgroundColor: t.palette.contrast_100, 345 borderColor: t.palette.contrast_400, 346 }) 347 + 348 + if (selected) { 349 + base.push({ 350 + backgroundColor: t.palette.primary_100, 351 + borderColor: t.palette.contrast_400, 352 + }) 353 + } 354 } 355 356 return { ··· 376 style={[ 377 a.justify_center, 378 a.align_center, 379 t.atoms.border_contrast_high, 380 + a.transition_color, 381 { 382 borderWidth: 1, 383 height: 24, 384 width: 24, 385 + borderRadius: 6, 386 }, 387 baseStyles, 388 hovered ? baseHoverStyles : {}, 389 ]}> 390 + {selected && <Checkmark width={14} fill={t.palette.white} />} 391 </View> 392 ) 393 } 394 395 export function Switch() { 396 const t = useTheme() 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 + 468 return ( 469 <View 470 style={[ 471 a.relative, 472 a.rounded_full, 473 t.atoms.bg, 474 { 475 + height: 28, 476 + width: 48, 477 padding: 3, 478 }, 479 + a.transition_color, 480 baseStyles, 481 hovered ? baseHoverStyles : {}, 482 ]}> 483 <Animated.View 484 + layout={LinearTransition.duration( 485 + platform({ 486 + web: 100, 487 + default: 200, 488 + }), 489 + ).easing(Easing.inOut(Easing.cubic))} 490 style={[ 491 a.rounded_full, 492 { 493 + backgroundColor: t.palette.white, 494 + height: 22, 495 + width: 22, 496 }, 497 + selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'}, 498 indicatorStyles, 499 ]} 500 /> ··· 505 export function Radio() { 506 const t = useTheme() 507 const {selected, hovered, focused, disabled, isInvalid} = 508 + useContext(ItemContext) 509 const {baseStyles, baseHoverStyles, indicatorStyles} = 510 createSharedToggleStyles({ 511 theme: t, ··· 522 a.align_center, 523 a.rounded_full, 524 t.atoms.border_contrast_high, 525 + a.transition_color, 526 { 527 borderWidth: 1, 528 + height: 25, 529 + width: 25, 530 + margin: -1, 531 }, 532 baseStyles, 533 hovered ? baseHoverStyles : {}, 534 ]}> 535 + {selected && ( 536 <View 537 style={[ 538 a.absolute, 539 a.rounded_full, 540 + {height: 12, width: 12}, 541 + {backgroundColor: t.palette.white}, 542 indicatorStyles, 543 ]} 544 /> 545 + )} 546 </View> 547 ) 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' 2 import { 3 type AccessibilityProps, 4 type TextStyle, ··· 20 multiple?: boolean 21 } 22 23 export function Group({children, multiple, ...props}: GroupProps) { 24 const t = useTheme() 25 return ( ··· 39 ) 40 } 41 42 export function Button({children, ...props}: ItemProps) { 43 return ( 44 <Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}> ··· 51 const t = useTheme() 52 const state = Toggle.useItemContext() 53 54 - const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => { 55 const base: ViewStyle[] = [] 56 const hover: ViewStyle[] = [] 57 const active: ViewStyle[] = [] ··· 112 ) 113 } 114 115 export function ButtonText({children}: {children: React.ReactNode}) { 116 const t = useTheme() 117 const state = Toggle.useItemContext() 118 119 - const textStyles = React.useMemo(() => { 120 const text: TextStyle[] = [] 121 if (state.selected) { 122 text.push(t.atoms.text_inverted)
··· 1 + import {useMemo} from 'react' 2 import { 3 type AccessibilityProps, 4 type TextStyle, ··· 20 multiple?: boolean 21 } 22 23 + /** 24 + * @deprecated - use SegmentedControl 25 + */ 26 export function Group({children, multiple, ...props}: GroupProps) { 27 const t = useTheme() 28 return ( ··· 42 ) 43 } 44 45 + /** 46 + * @deprecated - use SegmentedControl 47 + */ 48 export function Button({children, ...props}: ItemProps) { 49 return ( 50 <Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}> ··· 57 const t = useTheme() 58 const state = Toggle.useItemContext() 59 60 + const {baseStyles, hoverStyles, activeStyles} = useMemo(() => { 61 const base: ViewStyle[] = [] 62 const hover: ViewStyle[] = [] 63 const active: ViewStyle[] = [] ··· 118 ) 119 } 120 121 + /** 122 + * @deprecated - use SegmentedControl 123 + */ 124 export function ButtonText({children}: {children: React.ReactNode}) { 125 const t = useTheme() 126 const state = Toggle.useItemContext() 127 128 + const textStyles = useMemo(() => { 129 const text: TextStyle[] = [] 130 if (state.selected) { 131 text.push(t.atoms.text_inverted)
+7
src/components/icons/Chevron.tsx
··· 19 export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 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 })
··· 19 export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 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 }) 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 } & Omit<SvgProps, 'style' | 'size'> 14 15 export const sizes = { 16 xs: 12, 17 sm: 16, 18 md: 20,
··· 13 } & Omit<SvgProps, 'style' | 'size'> 14 15 export const sizes = { 16 + '2xs': 8, 17 xs: 12, 18 sm: 16, 19 md: 20,
+1 -1
src/components/verification/VerificationsDialog.tsx
··· 34 verificationState: FullVerificationState 35 }) { 36 return ( 37 - <Dialog.Outer control={control}> 38 <Dialog.Handle /> 39 <Inner 40 control={control}
··· 34 verificationState: FullVerificationState 35 }) { 36 return ( 37 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 38 <Dialog.Handle /> 39 <Inner 40 control={control}
+9 -9
src/lib/media/manip.ts
··· 85 return 86 } 87 88 - // we're currently relying on the fact our CDN only serves pngs 89 // -prf 90 - const imageUri = await downloadImage(uri, createPath('png'), 5e3) 91 - const imagePath = await moveToPermanentPath(imageUri, '.png') 92 safeDeleteAsync(imageUri) 93 await Sharing.shareAsync(imagePath, { 94 - mimeType: 'image/png', 95 - UTI: 'image/png', 96 }) 97 } 98 ··· 101 export async function saveImageToMediaLibrary({uri}: {uri: string}) { 102 // download the file to cache 103 // NOTE 104 - // assuming PNG 105 - // we're currently relying on the fact our CDN only serves pngs 106 // -prf 107 - const imageUri = await downloadImage(uri, createPath('png'), 5e3) 108 - const imagePath = await moveToPermanentPath(imageUri, '.png') 109 110 // save 111 try {
··· 85 return 86 } 87 88 + // we're currently relying on the fact our CDN only serves jpegs 89 // -prf 90 + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 91 + const imagePath = await moveToPermanentPath(imageUri, '.jpg') 92 safeDeleteAsync(imageUri) 93 await Sharing.shareAsync(imagePath, { 94 + mimeType: 'image/jpeg', 95 + UTI: 'image/jpeg', 96 }) 97 } 98 ··· 101 export async function saveImageToMediaLibrary({uri}: {uri: string}) { 102 // download the file to cache 103 // NOTE 104 + // assuming JPEG 105 + // we're currently relying on the fact our CDN only serves jpegs 106 // -prf 107 + const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 108 + const imagePath = await moveToPermanentPath(imageUri, '.jpg') 109 110 // save 111 try {
+1 -1
src/lib/media/picker.e2e.tsx
··· 67 68 return { 69 path: item.path, 70 - mime: item.mime, 71 size: item.size, 72 width: item.width, 73 height: item.height,
··· 67 68 return { 69 path: item.path, 70 + mime: item.mimeType, 71 size: item.size, 72 width: item.width, 73 height: item.height,
+4 -1
src/lib/media/picker.tsx
··· 1 import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool' 2 import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker' 3 4 export { 5 openPicker, ··· 31 32 export async function openCropper(opts: OpenCropperOptions) { 33 const item = await ExpoImageCropTool.openCropperAsync({ 34 ...opts, 35 format: 'jpeg', 36 }) 37 38 return { 39 path: item.path, 40 - mime: item.mime, 41 size: item.size, 42 width: item.width, 43 height: item.height,
··· 1 import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool' 2 import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker' 3 + import {t} from '@lingui/macro' 4 5 export { 6 openPicker, ··· 32 33 export async function openCropper(opts: OpenCropperOptions) { 34 const item = await ExpoImageCropTool.openCropperAsync({ 35 + doneButtonText: t`Done`, 36 + cancelButtonText: t`Cancel`, 37 ...opts, 38 format: 'jpeg', 39 }) 40 41 return { 42 path: item.path, 43 + mime: item.mimeType, 44 size: item.size, 45 width: item.width, 46 height: item.height,
+6 -2
src/lib/strings/time.ts
··· 1 import {type I18n} from '@lingui/core' 2 3 - export function niceDate(i18n: I18n, date: number | string | Date) { 4 const d = new Date(date) 5 6 return i18n.date(d, { 7 - dateStyle: 'long', 8 timeStyle: 'short', 9 }) 10 }
··· 1 import {type I18n} from '@lingui/core' 2 3 + export function niceDate( 4 + i18n: I18n, 5 + date: number | string | Date, 6 + dateStyle: 'short' | 'medium' | 'long' | 'full' = 'long', 7 + ) { 8 const d = new Date(date) 9 10 return i18n.date(d, { 11 + dateStyle, 12 timeStyle: 'short', 13 }) 14 }
+35 -35
src/locale/locales/en/messages.po
··· 733 msgid "Add app password" 734 msgstr "" 735 736 - #: src/screens/Settings/AppPasswords.tsx:75 737 - #: src/screens/Settings/AppPasswords.tsx:83 738 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:111 739 msgid "Add App Password" 740 msgstr "" ··· 937 msgid "Allow replies from:" 938 msgstr "" 939 940 - #: src/screens/Settings/AppPasswords.tsx:200 941 msgid "Allows access to direct messages" 942 msgstr "" 943 ··· 1129 msgid "App Password" 1130 msgstr "" 1131 1132 - #: src/screens/Settings/AppPasswords.tsx:147 1133 msgctxt "toast" 1134 msgid "App password deleted" 1135 msgstr "" ··· 1152 msgstr "" 1153 1154 #: src/Navigation.tsx:351 1155 - #: src/screens/Settings/AppPasswords.tsx:51 1156 msgid "App Passwords" 1157 msgstr "" 1158 ··· 1213 msgid "Archived post" 1214 msgstr "" 1215 1216 - #: src/screens/Settings/AppPasswords.tsx:209 1217 msgid "Are you sure you want to delete the app password \"{0}\"?" 1218 msgstr "" 1219 ··· 1641 #: src/screens/Deactivated.tsx:158 1642 #: src/screens/Profile/Header/EditProfileDialog.tsx:218 1643 #: src/screens/Profile/Header/EditProfileDialog.tsx:226 1644 - #: src/screens/Search/Shell.tsx:369 1645 #: src/screens/Settings/AppIconSettings/index.tsx:44 1646 #: src/screens/Settings/AppIconSettings/index.tsx:230 1647 #: src/screens/Settings/components/ChangeHandleDialog.tsx:78 ··· 2532 msgid "Create user list" 2533 msgstr "" 2534 2535 - #: src/screens/Settings/AppPasswords.tsx:174 2536 msgid "Created {0}" 2537 msgstr "" 2538 ··· 2624 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:736 2625 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 2626 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:280 2627 - #: src/screens/Settings/AppPasswords.tsx:212 2628 #: src/screens/StarterPack/StarterPackScreen.tsx:601 2629 #: src/screens/StarterPack/StarterPackScreen.tsx:690 2630 #: src/screens/StarterPack/StarterPackScreen.tsx:762 ··· 2640 msgid "Delete Account <0>\"</0><1>{0}</1><2>\"</2>" 2641 msgstr "" 2642 2643 - #: src/screens/Settings/AppPasswords.tsx:187 2644 msgid "Delete app password" 2645 msgstr "" 2646 2647 - #: src/screens/Settings/AppPasswords.tsx:207 2648 msgid "Delete app password?" 2649 msgstr "" 2650 ··· 2884 msgid "Does not include nudity." 2885 msgstr "" 2886 2887 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:517 2888 msgid "Domain verified!" 2889 msgstr "" 2890 ··· 3477 msgid "Failed to add to starter pack" 3478 msgstr "" 3479 3480 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:597 3481 msgid "Failed to change handle. Please try again." 3482 msgstr "" 3483 ··· 3790 msgid "Find people to follow" 3791 msgstr "" 3792 3793 - #: src/screens/Search/Shell.tsx:525 3794 msgid "Find posts, users, and feeds on Bluesky" 3795 msgstr "" 3796 ··· 4231 msgid "Handle" 4232 msgstr "" 4233 4234 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:601 4235 msgid "Handle already taken. Please try a different one." 4236 msgstr "" 4237 ··· 4240 msgid "Handle changed!" 4241 msgstr "" 4242 4243 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:605 4244 msgid "Handle too long. Please try a shorter one." 4245 msgstr "" 4246 ··· 4620 msgid "Invalid 2FA confirmation code." 4621 msgstr "" 4622 4623 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:607 4624 msgid "Invalid handle. Please try a different one." 4625 msgstr "" 4626 ··· 5513 msgid "Never lose access to your followers or data." 5514 msgstr "" 5515 5516 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:577 5517 msgid "Nevermind, create a handle for me" 5518 msgstr "" 5519 ··· 5656 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." 5657 msgstr "" 5658 5659 - #: src/screens/Settings/AppPasswords.tsx:108 5660 msgid "No app passwords yet" 5661 msgstr "" 5662 ··· 5980 #: src/components/Lists.tsx:173 5981 #: src/components/StarterPack/ProfileStarterPacks.tsx:328 5982 #: src/components/StarterPack/ProfileStarterPacks.tsx:337 5983 - #: src/screens/Settings/AppPasswords.tsx:59 5984 #: src/screens/Settings/components/ChangeHandleDialog.tsx:106 5985 #: src/view/screens/Profile.tsx:125 5986 msgid "Oops!" ··· 6845 msgid "Quotes of this post" 6846 msgstr "" 6847 6848 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:610 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 msgstr "" 6851 ··· 7453 7454 #: src/screens/Profile/ProfileFeed/index.tsx:93 7455 #: src/screens/ProfileList/components/ErrorScreen.tsx:35 7456 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:569 7457 #: src/screens/VideoFeed/index.tsx:1163 7458 #: src/view/screens/NotFound.tsx:60 7459 msgid "Returns to previous page" ··· 7571 #: src/components/forms/SearchInput.tsx:34 7572 #: src/components/forms/SearchInput.tsx:36 7573 #: src/screens/Search/Shell.tsx:327 7574 - #: src/screens/Search/Shell.tsx:513 7575 #: src/view/shell/bottom-bar/BottomBar.tsx:198 7576 msgid "Search" 7577 msgstr "" ··· 7731 msgid "Select account" 7732 msgstr "" 7733 7734 - #: src/components/AppLanguageDropdown.tsx:60 7735 msgid "Select an app language" 7736 msgstr "" 7737 ··· 8868 msgid "There was an issue fetching the list. Tap here to try again." 8869 msgstr "" 8870 8871 - #: src/screens/Settings/AppPasswords.tsx:60 8872 msgid "There was an issue fetching your app passwords" 8873 msgstr "" 8874 ··· 9038 msgid "This feed is no longer online. We are showing <0>Discover</0> instead." 9039 msgstr "" 9040 9041 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:603 9042 msgid "This handle is reserved. Please try a different one." 9043 msgstr "" 9044 ··· 9558 msgid "Update email" 9559 msgstr "" 9560 9561 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:536 9562 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:557 9563 msgid "Update to {domain}" 9564 msgstr "" 9565 ··· 9621 msgid "Uploading video..." 9622 msgstr "" 9623 9624 - #: src/screens/Settings/AppPasswords.tsx:67 9625 msgid "Use app passwords to sign in to other Bluesky clients without giving full access to your account or password." 9626 msgstr "" 9627 9628 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:568 9629 msgid "Use default provider" 9630 msgstr "" 9631 ··· 9785 msgid "Verify code" 9786 msgstr "" 9787 9788 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:538 9789 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:559 9790 msgid "Verify DNS Record" 9791 msgstr "" 9792 ··· 9804 msgid "Verify now" 9805 msgstr "" 9806 9807 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:539 9808 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:561 9809 msgid "Verify Text File" 9810 msgstr "" 9811 ··· 10817 msgid "Your choice will be remembered for future links. You can change it at any time in settings." 10818 msgstr "" 10819 10820 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:523 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 msgstr "" 10823
··· 733 msgid "Add app password" 734 msgstr "" 735 736 + #: src/screens/Settings/AppPasswords.tsx:73 737 + #: src/screens/Settings/AppPasswords.tsx:81 738 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:111 739 msgid "Add App Password" 740 msgstr "" ··· 937 msgid "Allow replies from:" 938 msgstr "" 939 940 + #: src/screens/Settings/AppPasswords.tsx:199 941 msgid "Allows access to direct messages" 942 msgstr "" 943 ··· 1129 msgid "App Password" 1130 msgstr "" 1131 1132 + #: src/screens/Settings/AppPasswords.tsx:145 1133 msgctxt "toast" 1134 msgid "App password deleted" 1135 msgstr "" ··· 1152 msgstr "" 1153 1154 #: src/Navigation.tsx:351 1155 + #: src/screens/Settings/AppPasswords.tsx:49 1156 msgid "App Passwords" 1157 msgstr "" 1158 ··· 1213 msgid "Archived post" 1214 msgstr "" 1215 1216 + #: src/screens/Settings/AppPasswords.tsx:208 1217 msgid "Are you sure you want to delete the app password \"{0}\"?" 1218 msgstr "" 1219 ··· 1641 #: src/screens/Deactivated.tsx:158 1642 #: src/screens/Profile/Header/EditProfileDialog.tsx:218 1643 #: src/screens/Profile/Header/EditProfileDialog.tsx:226 1644 + #: src/screens/Search/Shell.tsx:370 1645 #: src/screens/Settings/AppIconSettings/index.tsx:44 1646 #: src/screens/Settings/AppIconSettings/index.tsx:230 1647 #: src/screens/Settings/components/ChangeHandleDialog.tsx:78 ··· 2532 msgid "Create user list" 2533 msgstr "" 2534 2535 + #: src/screens/Settings/AppPasswords.tsx:172 2536 msgid "Created {0}" 2537 msgstr "" 2538 ··· 2624 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:736 2625 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 2626 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:280 2627 + #: src/screens/Settings/AppPasswords.tsx:211 2628 #: src/screens/StarterPack/StarterPackScreen.tsx:601 2629 #: src/screens/StarterPack/StarterPackScreen.tsx:690 2630 #: src/screens/StarterPack/StarterPackScreen.tsx:762 ··· 2640 msgid "Delete Account <0>\"</0><1>{0}</1><2>\"</2>" 2641 msgstr "" 2642 2643 + #: src/screens/Settings/AppPasswords.tsx:185 2644 msgid "Delete app password" 2645 msgstr "" 2646 2647 + #: src/screens/Settings/AppPasswords.tsx:206 2648 msgid "Delete app password?" 2649 msgstr "" 2650 ··· 2884 msgid "Does not include nudity." 2885 msgstr "" 2886 2887 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:522 2888 msgid "Domain verified!" 2889 msgstr "" 2890 ··· 3477 msgid "Failed to add to starter pack" 3478 msgstr "" 3479 3480 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:602 3481 msgid "Failed to change handle. Please try again." 3482 msgstr "" 3483 ··· 3790 msgid "Find people to follow" 3791 msgstr "" 3792 3793 + #: src/screens/Search/Shell.tsx:526 3794 msgid "Find posts, users, and feeds on Bluesky" 3795 msgstr "" 3796 ··· 4231 msgid "Handle" 4232 msgstr "" 4233 4234 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:606 4235 msgid "Handle already taken. Please try a different one." 4236 msgstr "" 4237 ··· 4240 msgid "Handle changed!" 4241 msgstr "" 4242 4243 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:610 4244 msgid "Handle too long. Please try a shorter one." 4245 msgstr "" 4246 ··· 4620 msgid "Invalid 2FA confirmation code." 4621 msgstr "" 4622 4623 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:612 4624 msgid "Invalid handle. Please try a different one." 4625 msgstr "" 4626 ··· 5513 msgid "Never lose access to your followers or data." 5514 msgstr "" 5515 5516 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:582 5517 msgid "Nevermind, create a handle for me" 5518 msgstr "" 5519 ··· 5656 msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." 5657 msgstr "" 5658 5659 + #: src/screens/Settings/AppPasswords.tsx:106 5660 msgid "No app passwords yet" 5661 msgstr "" 5662 ··· 5980 #: src/components/Lists.tsx:173 5981 #: src/components/StarterPack/ProfileStarterPacks.tsx:328 5982 #: src/components/StarterPack/ProfileStarterPacks.tsx:337 5983 + #: src/screens/Settings/AppPasswords.tsx:57 5984 #: src/screens/Settings/components/ChangeHandleDialog.tsx:106 5985 #: src/view/screens/Profile.tsx:125 5986 msgid "Oops!" ··· 6845 msgid "Quotes of this post" 6846 msgstr "" 6847 6848 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:615 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 msgstr "" 6851 ··· 7453 7454 #: src/screens/Profile/ProfileFeed/index.tsx:93 7455 #: src/screens/ProfileList/components/ErrorScreen.tsx:35 7456 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:574 7457 #: src/screens/VideoFeed/index.tsx:1163 7458 #: src/view/screens/NotFound.tsx:60 7459 msgid "Returns to previous page" ··· 7571 #: src/components/forms/SearchInput.tsx:34 7572 #: src/components/forms/SearchInput.tsx:36 7573 #: src/screens/Search/Shell.tsx:327 7574 + #: src/screens/Search/Shell.tsx:514 7575 #: src/view/shell/bottom-bar/BottomBar.tsx:198 7576 msgid "Search" 7577 msgstr "" ··· 7731 msgid "Select account" 7732 msgstr "" 7733 7734 + #: src/components/AppLanguageDropdown.tsx:61 7735 msgid "Select an app language" 7736 msgstr "" 7737 ··· 8868 msgid "There was an issue fetching the list. Tap here to try again." 8869 msgstr "" 8870 8871 + #: src/screens/Settings/AppPasswords.tsx:58 8872 msgid "There was an issue fetching your app passwords" 8873 msgstr "" 8874 ··· 9038 msgid "This feed is no longer online. We are showing <0>Discover</0> instead." 9039 msgstr "" 9040 9041 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:608 9042 msgid "This handle is reserved. Please try a different one." 9043 msgstr "" 9044 ··· 9558 msgid "Update email" 9559 msgstr "" 9560 9561 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:541 9562 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:562 9563 msgid "Update to {domain}" 9564 msgstr "" 9565 ··· 9621 msgid "Uploading video..." 9622 msgstr "" 9623 9624 + #: src/screens/Settings/AppPasswords.tsx:65 9625 msgid "Use app passwords to sign in to other Bluesky clients without giving full access to your account or password." 9626 msgstr "" 9627 9628 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:573 9629 msgid "Use default provider" 9630 msgstr "" 9631 ··· 9785 msgid "Verify code" 9786 msgstr "" 9787 9788 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:543 9789 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:564 9790 msgid "Verify DNS Record" 9791 msgstr "" 9792 ··· 9804 msgid "Verify now" 9805 msgstr "" 9806 9807 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:544 9808 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:566 9809 msgid "Verify Text File" 9810 msgstr "" 9811 ··· 10817 msgid "Your choice will be remembered for future links. You can change it at any time in settings." 10818 msgstr "" 10819 10820 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:528 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 msgstr "" 10823
+1 -1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 587 <BackdatedPostIndicator post={post} /> 588 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 589 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 590 - {niceDate(i18n, post.indexedAt)} 591 </Text> 592 {isRootPost && ( 593 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
··· 587 <BackdatedPostIndicator post={post} /> 588 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 589 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 590 + {niceDate(i18n, post.indexedAt, 'medium')} 591 </Text> 592 {isRootPost && ( 593 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+1 -1
src/screens/Profile/Header/Handle.tsx
··· 37 pointerEvents={disableTaps ? 'none' : isIOS ? 'auto' : 'box-none'}> 38 <NewskieDialog profile={profile} disabled={disableTaps} /> 39 {profile.viewer?.followedBy && !blockHide ? ( 40 - <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}> 41 <Text style={[t.atoms.text, a.text_sm]}> 42 <Trans>Follows you</Trans> 43 </Text>
··· 37 pointerEvents={disableTaps ? 'none' : isIOS ? 'auto' : 'box-none'}> 38 <NewskieDialog profile={profile} disabled={disableTaps} /> 39 {profile.viewer?.followedBy && !blockHide ? ( 40 + <View style={[t.atoms.bg_contrast_50, a.rounded_xs, a.px_sm, a.py_xs]}> 41 <Text style={[t.atoms.text, a.text_sm]}> 42 <Trans>Follows you</Trans> 43 </Text>
+2 -1
src/screens/Search/Shell.tsx
··· 362 size="large" 363 variant="ghost" 364 color="secondary" 365 - style={[a.px_sm, a.rounded_sm]} 366 onPress={onPressCancelSearch} 367 hitSlop={HITSLOP_10}> 368 <ButtonText>
··· 362 size="large" 363 variant="ghost" 364 color="secondary" 365 + shape="rectangular" 366 + style={[a.px_sm]} 367 onPress={onPressCancelSearch} 368 hitSlop={HITSLOP_10}> 369 <ButtonText>
+2 -3
src/screens/Settings/AppPasswords.tsx
··· 5 FadeOut, 6 LayoutAnimationConfig, 7 LinearTransition, 8 - StretchOutY, 9 } from 'react-native-reanimated' 10 import {type ComAtprotoServerListAppPasswords} from '@atproto/api' 11 import {msg, Trans} from '@lingui/macro' ··· 14 15 import {type CommonNavigatorParams} from '#/lib/routes/types' 16 import {cleanError} from '#/lib/strings/errors' 17 - import {isWeb} from '#/platform/detection' 18 import { 19 useAppPasswordDeleteMutation, 20 useAppPasswordsQuery, ··· 94 key={appPassword.name} 95 style={a.w_full} 96 entering={FadeIn} 97 - exiting={isWeb ? FadeOut : StretchOutY} 98 layout={LinearTransition.delay(150)}> 99 <SettingsList.Item> 100 <AppPasswordCard appPassword={appPassword} /> ··· 188 variant="ghost" 189 color="negative" 190 size="small" 191 style={[a.bg_transparent]} 192 onPress={() => deleteControl.open()}> 193 <ButtonIcon icon={TrashIcon} />
··· 5 FadeOut, 6 LayoutAnimationConfig, 7 LinearTransition, 8 } from 'react-native-reanimated' 9 import {type ComAtprotoServerListAppPasswords} from '@atproto/api' 10 import {msg, Trans} from '@lingui/macro' ··· 13 14 import {type CommonNavigatorParams} from '#/lib/routes/types' 15 import {cleanError} from '#/lib/strings/errors' 16 import { 17 useAppPasswordDeleteMutation, 18 useAppPasswordsQuery, ··· 92 key={appPassword.name} 93 style={a.w_full} 94 entering={FadeIn} 95 + exiting={FadeOut} 96 layout={LinearTransition.delay(150)}> 97 <SettingsList.Item> 98 <AppPasswordCard appPassword={appPassword} /> ··· 186 variant="ghost" 187 color="negative" 188 size="small" 189 + shape="square" 190 style={[a.bg_transparent]} 191 onPress={() => deleteControl.open()}> 192 <ButtonIcon icon={TrashIcon} />
+33 -40
src/screens/Settings/AppearanceSettings.tsx
··· 15 import {isNative} from '#/platform/detection' 16 import {useSetThemePrefs, useThemePrefs} from '#/state/shell' 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' 20 import {type Props as SVGIconProps} from '#/components/icons/common' 21 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' 22 import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' ··· 36 const {setColorMode, setDarkTheme} = useSetThemePrefs() 37 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) 47 }, 48 - [setColorMode, colorMode], 49 ) 50 51 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) 59 }, 60 - [setDarkTheme, darkTheme], 61 ) 62 63 const onChangeFontFamily = useCallback( 64 - (values: string[]) => { 65 - const next = values[0] === 'system' ? 'system' : 'theme' 66 - fonts.setFontFamily(next) 67 }, 68 [fonts], 69 ) 70 71 const onChangeFontScale = useCallback( 72 - (values: string[]) => { 73 - const next = values[0] || ('0' as any) 74 - fonts.setFontScale(next) 75 }, 76 [fonts], 77 ) ··· 107 name: 'dark', 108 }, 109 ]} 110 - values={[colorMode]} 111 onChange={onChangeAppearance} 112 /> 113 ··· 128 name: 'dark', 129 }, 130 ]} 131 - values={[darkTheme ?? 'dim']} 132 onChange={onChangeDarkTheme} 133 /> 134 </Animated.View> ··· 153 name: 'theme', 154 }, 155 ]} 156 - values={[fonts.family]} 157 onChange={onChangeFontFamily} 158 /> 159 ··· 174 name: '1', 175 }, 176 ]} 177 - values={[fonts.scale]} 178 onChange={onChangeFontScale} 179 /> 180 ··· 192 ) 193 } 194 195 - export function AppearanceToggleButtonGroup({ 196 title, 197 description, 198 icon: Icon, 199 items, 200 - values, 201 onChange, 202 }: { 203 title: string ··· 205 icon: React.ComponentType<SVGIconProps> 206 items: { 207 label: string 208 - name: string 209 }[] 210 - values: string[] 211 - onChange: (values: string[]) => void 212 }) { 213 const t = useTheme() 214 return ( ··· 227 {description} 228 </Text> 229 )} 230 - <ToggleButton.Group label={title} values={values} onChange={onChange}> 231 {items.map(item => ( 232 - <ToggleButton.Button 233 key={item.name} 234 label={item.label} 235 - name={item.name}> 236 - <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText> 237 - </ToggleButton.Button> 238 ))} 239 - </ToggleButton.Group> 240 </SettingsList.Group> 241 </> 242 )
··· 15 import {isNative} from '#/platform/detection' 16 import {useSetThemePrefs, useThemePrefs} from '#/state/shell' 17 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' 18 + import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf' 19 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 20 import {type Props as SVGIconProps} from '#/components/icons/common' 21 import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' 22 import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' ··· 36 const {setColorMode, setDarkTheme} = useSetThemePrefs() 37 38 const onChangeAppearance = useCallback( 39 + (value: 'light' | 'system' | 'dark') => { 40 + setColorMode(value) 41 }, 42 + [setColorMode], 43 ) 44 45 const onChangeDarkTheme = useCallback( 46 + (value: 'dim' | 'dark') => { 47 + setDarkTheme(value) 48 }, 49 + [setDarkTheme], 50 ) 51 52 const onChangeFontFamily = useCallback( 53 + (value: 'system' | 'theme') => { 54 + fonts.setFontFamily(value) 55 }, 56 [fonts], 57 ) 58 59 const onChangeFontScale = useCallback( 60 + (value: Alf['fonts']['scale']) => { 61 + fonts.setFontScale(value) 62 }, 63 [fonts], 64 ) ··· 94 name: 'dark', 95 }, 96 ]} 97 + value={colorMode} 98 onChange={onChangeAppearance} 99 /> 100 ··· 115 name: 'dark', 116 }, 117 ]} 118 + value={darkTheme ?? 'dim'} 119 onChange={onChangeDarkTheme} 120 /> 121 </Animated.View> ··· 140 name: 'theme', 141 }, 142 ]} 143 + value={fonts.family} 144 onChange={onChangeFontFamily} 145 /> 146 ··· 161 name: '1', 162 }, 163 ]} 164 + value={fonts.scale} 165 onChange={onChangeFontScale} 166 /> 167 ··· 179 ) 180 } 181 182 + export function AppearanceToggleButtonGroup<T extends string>({ 183 title, 184 description, 185 icon: Icon, 186 items, 187 + value, 188 onChange, 189 }: { 190 title: string ··· 192 icon: React.ComponentType<SVGIconProps> 193 items: { 194 label: string 195 + name: T 196 }[] 197 + value: T 198 + onChange: (value: T) => void 199 }) { 200 const t = useTheme() 201 return ( ··· 214 {description} 215 </Text> 216 )} 217 + <SegmentedControl.Root 218 + type="radio" 219 + label={title} 220 + value={value} 221 + onChange={onChange}> 222 {items.map(item => ( 223 + <SegmentedControl.Item 224 key={item.name} 225 label={item.label} 226 + value={item.name}> 227 + <SegmentedControl.ItemText> 228 + {item.label} 229 + </SegmentedControl.ItemText> 230 + </SegmentedControl.Item> 231 ))} 232 + </SegmentedControl.Root> 233 </SettingsList.Group> 234 </> 235 )
+1 -1
src/screens/Settings/LanguageSettings.tsx
··· 164 label={_(msg`Select content languages`)} 165 size="small" 166 color="secondary" 167 - variant="solid" 168 onPress={onPressContentLanguages} 169 style={[a.justify_start, web({maxWidth: 400})]}> 170 <ButtonIcon
··· 164 label={_(msg`Select content languages`)} 165 size="small" 166 color="secondary" 167 + shape="rectangular" 168 onPress={onPressContentLanguages} 169 style={[a.justify_start, web({maxWidth: 400})]}> 170 <ButtonIcon
+21 -15
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 29 import {Admonition} from '#/components/Admonition' 30 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31 import * as Dialog from '#/components/Dialog' 32 import * as TextField from '#/components/forms/TextField' 33 - import * as ToggleButton from '#/components/forms/ToggleButton' 34 import { 35 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 36 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, ··· 395 /> 396 </TextField.Root> 397 </View> 398 - <ToggleButton.Group 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> 404 <Trans>DNS Panel</Trans> 405 - </ToggleButton.ButtonText> 406 - </ToggleButton.Button> 407 - <ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}> 408 - <ToggleButton.ButtonText> 409 <Trans>No DNS Panel</Trans> 410 - </ToggleButton.ButtonText> 411 - </ToggleButton.Button> 412 - </ToggleButton.Group> 413 {dnsPanel ? ( 414 <> 415 <Text> ··· 500 value={currentAccount?.did ?? ''} 501 label={_(msg`Copy DID`)} 502 size="large" 503 - variant="solid" 504 color="secondary" 505 - style={[a.px_md, a.border, t.atoms.border_contrast_low]}> 506 <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text> 507 <ButtonIcon icon={CopyIcon} /> 508 </CopyButton>
··· 29 import {Admonition} from '#/components/Admonition' 30 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31 import * as Dialog from '#/components/Dialog' 32 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 33 import * as TextField from '#/components/forms/TextField' 34 import { 35 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 36 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, ··· 395 /> 396 </TextField.Root> 397 </View> 398 + <SegmentedControl.Root 399 label={_(msg`Choose domain verification method`)} 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> 405 <Trans>DNS Panel</Trans> 406 + </SegmentedControl.ItemText> 407 + </SegmentedControl.Item> 408 + <SegmentedControl.Item value="file" label={_(msg`No DNS Panel`)}> 409 + <SegmentedControl.ItemText> 410 <Trans>No DNS Panel</Trans> 411 + </SegmentedControl.ItemText> 412 + </SegmentedControl.Item> 413 + </SegmentedControl.Root> 414 {dnsPanel ? ( 415 <> 416 <Text> ··· 501 value={currentAccount?.did ?? ''} 502 label={_(msg`Copy DID`)} 503 size="large" 504 + shape="rectangular" 505 color="secondary" 506 + style={[ 507 + a.px_md, 508 + a.border, 509 + t.atoms.border_contrast_low, 510 + t.atoms.bg_contrast_25, 511 + ]}> 512 <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text> 513 <ButtonIcon icon={CopyIcon} /> 514 </CopyButton>
+1
src/screens/Settings/components/SettingsList.tsx
··· 194 * also so that we can calculate transforms. 195 */ 196 const iconSize = { 197 xs: 12, 198 sm: 16, 199 md: 20,
··· 194 * also so that we can calculate transforms. 195 */ 196 const iconSize = { 197 + '2xs': 8, 198 xs: 12, 199 sm: 16, 200 md: 20,
+6 -2
src/state/global-gesture-events/index.tsx
··· 1 import {createContext, useContext, useMemo, useRef, useState} from 'react' 2 - import {View} from 'react-native' 3 import { 4 Gesture, 5 GestureDetector, ··· 29 30 export function GlobalGestureEventsProvider({ 31 children, 32 }: { 33 children: React.ReactNode 34 }) { 35 const refCount = useRef(0) 36 const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) ··· 73 return ( 74 <Context.Provider value={ctx}> 75 <GestureDetector gesture={gesture}> 76 - <View collapsable={false}>{children}</View> 77 </GestureDetector> 78 </Context.Provider> 79 )
··· 1 import {createContext, useContext, useMemo, useRef, useState} from 'react' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 import { 4 Gesture, 5 GestureDetector, ··· 29 30 export function GlobalGestureEventsProvider({ 31 children, 32 + style, 33 }: { 34 children: React.ReactNode 35 + style?: StyleProp<ViewStyle> 36 }) { 37 const refCount = useRef(0) 38 const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) ··· 75 return ( 76 <Context.Provider value={ctx}> 77 <GestureDetector gesture={gesture}> 78 + <View collapsable={false} style={style}> 79 + {children} 80 + </View> 81 </GestureDetector> 82 </Context.Provider> 83 )
+9 -1
src/state/queries/post-interaction-settings.ts
··· 4 import {preferencesQueryKey} from '#/state/queries/preferences' 5 import {useAgent} from '#/state/session' 6 7 - export function usePostInteractionSettingsMutation() { 8 const qc = useQueryClient() 9 const agent = useAgent() 10 return useMutation({ ··· 16 queryKey: preferencesQueryKey, 17 }) 18 }, 19 }) 20 }
··· 4 import {preferencesQueryKey} from '#/state/queries/preferences' 5 import {useAgent} from '#/state/session' 6 7 + export function usePostInteractionSettingsMutation({ 8 + onError, 9 + onSettled, 10 + }: { 11 + onError?: (error: Error) => void 12 + onSettled?: () => void 13 + } = {}) { 14 const qc = useQueryClient() 15 const agent = useAgent() 16 return useMutation({ ··· 22 queryKey: preferencesQueryKey, 23 }) 24 }, 25 + onError, 26 + onSettled, 27 }) 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 // deer 41 deerGateCache: string 42 activitySubscriptionsNudged?: boolean 43 44 /** 45 * Policy update overlays. New IDs are required for each new announcement.
··· 40 // deer 41 deerGateCache: string 42 activitySubscriptionsNudged?: boolean 43 + threadgateNudged?: boolean 44 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 import {useLingui} from '@lingui/react' 6 7 import {BSKY_SERVICE} from '#/lib/constants' 8 - import {logEvent} from '#/lib/statsig/statsig' 9 import * as persisted from '#/state/persisted' 10 import {useSession} from '#/state/session' 11 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 import {Admonition} from '#/components/Admonition' 13 import {Button, ButtonText} from '#/components/Button' 14 import * as Dialog from '#/components/Dialog' 15 import * as TextField from '#/components/forms/TextField' 16 - import * as ToggleButton from '#/components/forms/ToggleButton' 17 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 import {InlineLinkText} from '#/components/Link' 19 - import {P, Text} from '#/components/Typography' 20 21 export function ServerInputDialog({ 22 control, ··· 29 const formRef = useRef<DialogInnerRef>(null) 30 31 // persist these options between dialog open/close 32 - const [fixedOption, setFixedOption] = useState(BSKY_SERVICE) 33 const [previousCustomAddress, setPreviousCustomAddress] = useState('') 34 35 const onClose = useCallback(() => { ··· 40 setPreviousCustomAddress(result) 41 } 42 } 43 - logEvent('signin:hostingProviderPressed', { 44 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 45 }) 46 }, [onSelect, fixedOption]) ··· 49 <Dialog.Outer 50 control={control} 51 onClose={onClose} 52 - nativeOptions={{minHeight: height / 2}}> 53 <Dialog.Handle /> 54 <DialogInner 55 formRef={formRef} ··· 70 initialCustomAddress, 71 }: { 72 formRef: React.Ref<DialogInnerRef> 73 - fixedOption: string 74 - setFixedOption: (opt: string) => void 75 initialCustomAddress: string 76 }) { 77 const control = Dialog.useDialogContext() ··· 124 return ( 125 <Dialog.ScrollableInner 126 accessibilityDescribedBy="dialog-description" 127 - accessibilityLabelledBy="dialog-title"> 128 <View style={[a.relative, a.gap_md, a.w_full]}> 129 - <Text nativeID="dialog-title" style={[a.text_2xl, a.font_semi_bold]}> 130 <Trans>Choose your account provider</Trans> 131 </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 140 testID="customSelectBtn" 141 - name="custom" 142 label={_(msg`Custom`)}> 143 - <ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText> 144 - </ToggleButton.Button> 145 - </ToggleButton.Group> 146 147 {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> 155 )} 156 157 {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 - ]}> 166 <TextField.LabelText nativeID="address-input-label"> 167 <Trans>Server address</Trans> 168 </TextField.LabelText> ··· 197 )} 198 199 <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 - ]}> 207 {isFirstTimeUser ? ( 208 <Trans> 209 If you're a developer, you can host your own server. ··· 219 to="https://atproto.com/guides/self-hosting"> 220 <Trans>Learn more.</Trans> 221 </InlineLinkText> 222 - </P> 223 </View> 224 225 <View style={gtMobile && [a.flex_row, a.justify_end]}> 226 <Button 227 testID="doneBtn" 228 - variant="outline" 229 color="primary" 230 - size="small" 231 onPress={() => control.close()} 232 label={_(msg`Done`)}> 233 - <ButtonText>{_(msg`Done`)}</ButtonText> 234 </Button> 235 </View> 236 </View>
··· 5 import {useLingui} from '@lingui/react' 6 7 import {BSKY_SERVICE} from '#/lib/constants' 8 + import {logger} from '#/logger' 9 import * as persisted from '#/state/persisted' 10 import {useSession} from '#/state/session' 11 + import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' 12 import {Admonition} from '#/components/Admonition' 13 import {Button, ButtonText} from '#/components/Button' 14 import * as Dialog from '#/components/Dialog' 15 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 16 import * as TextField from '#/components/forms/TextField' 17 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 18 import {InlineLinkText} from '#/components/Link' 19 + import {Text} from '#/components/Typography' 20 + 21 + type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' 22 23 export function ServerInputDialog({ 24 control, ··· 31 const formRef = useRef<DialogInnerRef>(null) 32 33 // persist these options between dialog open/close 34 + const [fixedOption, setFixedOption] = 35 + useState<SegmentedControlOptions>(BSKY_SERVICE) 36 const [previousCustomAddress, setPreviousCustomAddress] = useState('') 37 38 const onClose = useCallback(() => { ··· 43 setPreviousCustomAddress(result) 44 } 45 } 46 + logger.metric('signin:hostingProviderPressed', { 47 hostingProviderDidChange: fixedOption !== BSKY_SERVICE, 48 }) 49 }, [onSelect, fixedOption]) ··· 52 <Dialog.Outer 53 control={control} 54 onClose={onClose} 55 + nativeOptions={platform({ 56 + android: {minHeight: height / 2}, 57 + ios: {preventExpansion: true}, 58 + })}> 59 <Dialog.Handle /> 60 <DialogInner 61 formRef={formRef} ··· 76 initialCustomAddress, 77 }: { 78 formRef: React.Ref<DialogInnerRef> 79 + fixedOption: SegmentedControlOptions 80 + setFixedOption: (opt: SegmentedControlOptions) => void 81 initialCustomAddress: string 82 }) { 83 const control = Dialog.useDialogContext() ··· 130 return ( 131 <Dialog.ScrollableInner 132 accessibilityDescribedBy="dialog-description" 133 + accessibilityLabelledBy="dialog-title" 134 + style={web({maxWidth: 500})}> 135 <View style={[a.relative, a.gap_md, a.w_full]}> 136 + <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> 137 <Trans>Choose your account provider</Trans> 138 </Text> 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 153 testID="customSelectBtn" 154 + value="custom" 155 label={_(msg`Custom`)}> 156 + <SegmentedControl.ItemText> 157 + {_(msg`Custom`)} 158 + </SegmentedControl.ItemText> 159 + </SegmentedControl.Item> 160 + </SegmentedControl.Root> 161 162 {fixedOption === BSKY_SERVICE && isFirstTimeUser && ( 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> 172 )} 173 174 {fixedOption === 'custom' && ( 175 + <View role="tabpanel"> 176 <TextField.LabelText nativeID="address-input-label"> 177 <Trans>Server address</Trans> 178 </TextField.LabelText> ··· 207 )} 208 209 <View style={[a.py_xs]}> 210 + <Text 211 + style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}> 212 {isFirstTimeUser ? ( 213 <Trans> 214 If you're a developer, you can host your own server. ··· 224 to="https://atproto.com/guides/self-hosting"> 225 <Trans>Learn more.</Trans> 226 </InlineLinkText> 227 + </Text> 228 </View> 229 230 <View style={gtMobile && [a.flex_row, a.justify_end]}> 231 <Button 232 testID="doneBtn" 233 + variant="solid" 234 color="primary" 235 + size={platform({ 236 + native: 'large', 237 + web: 'small', 238 + })} 239 onPress={() => control.close()} 240 label={_(msg`Done`)}> 241 + <ButtonText> 242 + <Trans>Done</Trans> 243 + </ButtonText> 244 </Button> 245 </View> 246 </View>
+4 -9
src/view/com/composer/labels/LabelsBtn.tsx
··· 10 type SelfLabel, 11 } from '#/lib/moderation' 12 import {isWeb} from '#/platform/detection' 13 - import {atoms as a, native, useTheme, web} from '#/alf' 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 import * as Dialog from '#/components/Dialog' 16 import * as Toggle from '#/components/forms/Toggle' 17 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' 19 import {Text} from '#/components/Typography' 20 ··· 49 return ( 50 <> 51 <Button 52 - variant="solid" 53 color="secondary" 54 size="small" 55 testID="labelsBtn" ··· 60 label={_(msg`Content warnings`)} 61 accessibilityHint={_( 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 - ]}> 70 <ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} /> 71 <ButtonText numberOfLines={1}> 72 {labels.length > 0 ? ( ··· 75 <Trans>Labels</Trans> 76 )} 77 </ButtonText> 78 </Button> 79 80 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
··· 10 type SelfLabel, 11 } from '#/lib/moderation' 12 import {isWeb} from '#/platform/detection' 13 + import {atoms as a, useTheme, web} from '#/alf' 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 import * as Dialog from '#/components/Dialog' 16 import * as Toggle from '#/components/forms/Toggle' 17 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' 19 import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' 20 import {Text} from '#/components/Typography' 21 ··· 50 return ( 51 <> 52 <Button 53 color="secondary" 54 size="small" 55 testID="labelsBtn" ··· 60 label={_(msg`Content warnings`)} 61 accessibilityHint={_( 62 msg`Opens a dialog to add a content warning to your post`, 63 + )}> 64 <ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} /> 65 <ButtonText numberOfLines={1}> 66 {labels.length > 0 ? ( ··· 69 <Trans>Labels</Trans> 70 )} 71 </ButtonText> 72 + <ButtonIcon icon={TinyChevronIcon} size="2xs" /> 73 </Button> 74 75 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+126 -25
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' 2 import {type AnimatedStyle} from 'react-native-reanimated' 3 import {type AppBskyFeedPostgate} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' 6 7 import {isNative} from '#/platform/detection' 8 - import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' 9 - import {native} from '#/alf' 10 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 import * as Dialog from '#/components/Dialog' 12 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' 15 16 export function ThreadgateBtn({ 17 postgate, ··· 29 }) { 30 const {_} = useLingui() 31 const control = Dialog.useDialogControl() 32 33 const onPress = () => { 34 if (isNative && Keyboard.isVisible()) { 35 Keyboard.dismiss() 36 } 37 38 control.open() 39 } 40 41 const anyoneCanReply = 42 threadgateAllowUISettings.length === 1 && 43 threadgateAllowUISettings[0].type === 'everybody' ··· 50 51 return ( 52 <> 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> 72 <PostInteractionSettingsControlledDialog 73 control={control} 74 onSave={() => { 75 - control.close() 76 }} 77 postgate={postgate} 78 onChangePostgate={onChangePostgate} 79 threadgateAllowUISettings={threadgateAllowUISettings} 80 onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} 81 /> 82 </> 83 )
··· 1 + import {useEffect, useMemo, useState} from 'react' 2 import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' 3 import {type AnimatedStyle} from 'react-native-reanimated' 4 import {type AppBskyFeedPostgate} from '@atproto/api' 5 + import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 + import deepEqual from 'lodash.isequal' 8 9 + import {isNetworkError} from '#/lib/strings/errors' 10 + import {logger} from '#/logger' 11 import {isNative} from '#/platform/detection' 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' 20 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 import * as Dialog from '#/components/Dialog' 22 import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' 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' 29 30 export function ThreadgateBtn({ 31 postgate, ··· 43 }) { 44 const {_} = useLingui() 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) 66 67 const onPress = () => { 68 if (isNative && Keyboard.isVisible()) { 69 Keyboard.dismiss() 70 } 71 72 + setShowTooltip(false) 73 + setThreadgateNudged(true) 74 + 75 control.open() 76 } 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 + 122 const anyoneCanReply = 123 threadgateAllowUISettings.length === 1 && 124 threadgateAllowUISettings[0].type === 'everybody' ··· 131 132 return ( 133 <> 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 + 160 <PostInteractionSettingsControlledDialog 161 control={control} 162 onSave={() => { 163 + if (persist) { 164 + persistChanges({ 165 + threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue( 166 + threadgateAllowUISettings, 167 + ), 168 + postgateEmbeddingRules: postgate.embeddingRules ?? [], 169 + }) 170 + } else { 171 + control.close() 172 + } 173 }} 174 + isSaving={isSaving} 175 postgate={postgate} 176 onChangePostgate={onChangePostgate} 177 threadgateAllowUISettings={threadgateAllowUISettings} 178 onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} 179 + isDirty={isDirty} 180 + persist={persist} 181 + onChangePersist={setPersist} 182 /> 183 </> 184 )
+5 -7
src/view/com/util/images/AutoSizedImage.tsx
··· 17 useHighQualityImages, 18 } from '#/state/preferences/high-quality-images' 19 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 20 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 21 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 22 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 23 import {Text} from '#/components/Typography' ··· 34 children: React.ReactNode 35 }) { 36 const t = useTheme() 37 - const {gtMobile} = useBreakpoints() 38 /** 39 * Computed as a % value to apply as `paddingTop`, this basically controls 40 * the height of the image. 41 */ 42 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 47 return `${ratio * 100}%` 48 - }, [aspectRatio, gtMobile, minMobileAspectRatio]) 49 50 return ( 51 <View style={[a.w_full]}>
··· 17 useHighQualityImages, 18 } from '#/state/preferences/high-quality-images' 19 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 20 + import {atoms as a, useTheme} from '#/alf' 21 import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 22 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 23 import {Text} from '#/components/Typography' ··· 34 children: React.ReactNode 35 }) { 36 const t = useTheme() 37 /** 38 * Computed as a % value to apply as `paddingTop`, this basically controls 39 * the height of the image. 40 */ 41 const outerAspectRatio = React.useMemo<DimensionValue>(() => { 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 45 return `${ratio * 100}%` 46 + }, [aspectRatio, minMobileAspectRatio]) 47 48 return ( 49 <View style={[a.w_full]}>
+32 -16
src/view/screens/Storybook/Forms.tsx
··· 4 import {atoms as a} from '#/alf' 5 import {Button, ButtonText} from '#/components/Button' 6 import {DateField, LabelText} from '#/components/forms/DateField' 7 import * as TextField from '#/components/forms/TextField' 8 import * as Toggle from '#/components/forms/Toggle' 9 import * as ToggleButton from '#/components/forms/ToggleButton' ··· 15 const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) 16 const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) 17 const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) 18 19 const [value, setValue] = React.useState('') 20 const [date, setDate] = React.useState('2001-01-01') ··· 155 </View> 156 </Toggle.Group> 157 158 <Toggle.Group 159 label="Toggle" 160 type="checkbox" ··· 245 <ToggleButton.ButtonText>Show</ToggleButton.ButtonText> 246 </ToggleButton.Button> 247 </ToggleButton.Group> 248 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> 265 </View> 266 </View> 267 )
··· 4 import {atoms as a} from '#/alf' 5 import {Button, ButtonText} from '#/components/Button' 6 import {DateField, LabelText} from '#/components/forms/DateField' 7 + import * as SegmentedControl from '#/components/forms/SegmentedControl' 8 import * as TextField from '#/components/forms/TextField' 9 import * as Toggle from '#/components/forms/Toggle' 10 import * as ToggleButton from '#/components/forms/ToggleButton' ··· 16 const [toggleGroupBValues, setToggleGroupBValues] = React.useState(['a', 'b']) 17 const [toggleGroupCValues, setToggleGroupCValues] = React.useState(['a', 'b']) 18 const [toggleGroupDValues, setToggleGroupDValues] = React.useState(['warn']) 19 + const [segmentedControlValue, setSegmentedControlValue] = React.useState< 20 + 'hide' | 'warn' | 'show' 21 + >('warn') 22 23 const [value, setValue] = React.useState('') 24 const [date, setDate] = React.useState('2001-01-01') ··· 159 </View> 160 </Toggle.Group> 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 + 171 <Toggle.Group 172 label="Toggle" 173 type="checkbox" ··· 258 <ToggleButton.ButtonText>Show</ToggleButton.ButtonText> 259 </ToggleButton.Button> 260 </ToggleButton.Group> 261 + </View> 262 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> 281 </View> 282 </View> 283 )
+15 -12
src/view/shell/Composer.ios.tsx
··· 3 4 import {useDialogStateControlContext} from '#/state/dialogs' 5 import {useComposerState} from '#/state/shell/composer' 6 import {atoms as a, useTheme} from '#/alf' 7 - import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 8 9 export function Composer({}: {winHeight: number}) { 10 const {setFullyExpandedCount} = useDialogStateControlContext() ··· 33 animationType="slide" 34 onRequestClose={() => ref.current?.onPressCancel()}> 35 <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 - /> 47 </View> 48 </Modal> 49 )
··· 3 4 import {useDialogStateControlContext} from '#/state/dialogs' 5 import {useComposerState} from '#/state/shell/composer' 6 + import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' 7 import {atoms as a, useTheme} from '#/alf' 8 + import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' 9 10 export function Composer({}: {winHeight: number}) { 11 const {setFullyExpandedCount} = useDialogStateControlContext() ··· 34 animationType="slide" 35 onRequestClose={() => ref.current?.onPressCancel()}> 36 <View style={[t.atoms.bg, a.flex_1]}> 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> 50 </View> 51 </Modal> 52 )
+4 -4
yarn.lock
··· 11348 resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" 11349 integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== 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== 11355 11356 expo-image-loader@~6.0.0: 11357 version "6.0.0"
··· 11348 resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" 11349 integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== 11350 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 11356 expo-image-loader@~6.0.0: 11357 version "6.0.0"