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