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