Merge branch 'main' into composer-prompt
This commit is contained in:
@@ -43,6 +43,7 @@ module.exports = {
|
||||
suggestedTextWrappers: {
|
||||
Button: 'ButtonText',
|
||||
'ToggleButton.Button': 'ToggleButton.ButtonText',
|
||||
'SegmentedControl.Item': 'SegmentedControl.ItemText',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
build:
|
||||
if: github.repository == 'bluesky-social/social-app'
|
||||
name: Build and Submit Android
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: Linux-x64-32core
|
||||
steps:
|
||||
- name: Check for EXPO_TOKEN
|
||||
run: >
|
||||
|
||||
@@ -31,9 +31,15 @@ appId: xyz.blueskyweb.app
|
||||
text: "Your account"
|
||||
- tapOn:
|
||||
id: "nextBtn"
|
||||
- waitForAnimationToEnd
|
||||
- tapOn:
|
||||
text: "Not Now"
|
||||
optional: true
|
||||
# save password thing no longer seems to be in hierarchy
|
||||
# tap on something else instead
|
||||
point: 20%,20%
|
||||
repeat: 2
|
||||
delay: 1000
|
||||
- tapOn:
|
||||
id: "handleInput"
|
||||
- inputText: "e2e-test"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -11,7 +11,7 @@ appId: xyz.blueskyweb.app
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Feeds ✨"
|
||||
text: "Feeds ✨"
|
||||
- tapOn:
|
||||
label: "Can go to feeds page using feeds button in tab bar"
|
||||
text: "Feeds ✨"
|
||||
@@ -50,6 +50,6 @@ appId: xyz.blueskyweb.app
|
||||
label: "Can delete posts"
|
||||
id: "postDropdownBtn"
|
||||
childOf:
|
||||
id: "feedItem-by-alice.test"
|
||||
id: "feedItem-by-alice.test"
|
||||
- tapOn: "Delete post"
|
||||
- tapOn: "Delete"
|
||||
|
||||
@@ -17,7 +17,8 @@ appId: xyz.blueskyweb.app
|
||||
index: 0
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn: "Create report for Misleading Post"
|
||||
- tapOn: "Create report for Misleading"
|
||||
- tapOn: "Create report for Spam"
|
||||
- tapOn: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
point: "50%,90%"
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "profileHeaderDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,20 +15,28 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Other misleading content"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:clearOption"
|
||||
id: "report:clearReportOption"
|
||||
- assertNotVisible:
|
||||
id: "report:details"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonSpam"
|
||||
id: "report:clearCategory"
|
||||
- assertNotVisible:
|
||||
id: "report:option:Other misleading content"
|
||||
- tapOn:
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Spam"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: Your report will be sent to Dev-env Moderation.*
|
||||
- tapOn:
|
||||
id: "report:submit"
|
||||
- assertNotVisible:
|
||||
|
||||
@@ -3,7 +3,7 @@ appId: xyz.blueskyweb.app
|
||||
- runScript:
|
||||
file: ../../setupServer.js
|
||||
env:
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
SERVER_PATH: ?users&follows&posts&feeds
|
||||
- runFlow:
|
||||
file: ../../setupApp.yml
|
||||
- tapOn:
|
||||
@@ -15,9 +15,11 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn:
|
||||
id: "postDropdownReportBtn"
|
||||
- tapOn:
|
||||
id: "report:option:com.atproto.moderation.defs#reasonOther"
|
||||
id: "report:category:Misleading"
|
||||
- tapOn:
|
||||
id: "report:option:Other misleading content"
|
||||
- assertVisible:
|
||||
id: "report:labeler:mod-authority.test"
|
||||
text: "Send report to Dev-env Moderation"
|
||||
# reason "other" defaults with details open
|
||||
- assertVisible:
|
||||
id: "report:details"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
# flow.yaml
|
||||
|
||||
appId: xyz.blueskyweb.app
|
||||
@@ -33,7 +32,7 @@ appId: xyz.blueskyweb.app
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
- swipe:
|
||||
direction: "LEFT"
|
||||
- swipe:
|
||||
direction: "RIGHT"
|
||||
|
||||
@@ -0,0 +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>
|
||||
|
After Width: | Height: | Size: 237 B |
+6
-1
@@ -243,13 +243,18 @@ class BottomSheetView(
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
val currentState = behavior.state
|
||||
|
||||
behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight)
|
||||
val oldRatio = behavior.halfExpandedRatio
|
||||
var newRatio = getHalfExpandedRatio(contentHeight)
|
||||
behavior.halfExpandedRatio = newRatio
|
||||
|
||||
if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
} else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {BottomSheet} from './src/BottomSheet'
|
||||
import {
|
||||
BottomSheetSnapPoint,
|
||||
BottomSheetState,
|
||||
BottomSheetViewProps,
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
} from './src/BottomSheet.types'
|
||||
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
|
||||
import {
|
||||
|
||||
@@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component<
|
||||
onStateChange={this.onStateChange}
|
||||
extraStyles={extraStyles}
|
||||
onLayout={e => {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
this.updateLayout()
|
||||
if (isIOS15) {
|
||||
const {height} = e.nativeEvent.layout
|
||||
this.setState({viewHeight: height})
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
// TEMP HACKFIX: I had to timebox this, but this is Bad.
|
||||
// On Android, if you run updateLayout() immediately,
|
||||
// it will take ages to actually run on the native side.
|
||||
// However, adding literally any delay will fix this, including
|
||||
// a console.log() - just sending the log to the CLI is enough.
|
||||
// TODO: Get to the bottom of this and fix it properly! -sfn
|
||||
setTimeout(() => this.updateLayout())
|
||||
} else {
|
||||
this.updateLayout()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-crop-tool": "^0.1.8",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-intent-launcher": "~13.0.7",
|
||||
|
||||
@@ -25,7 +25,8 @@ export function normalizeTextStyles(
|
||||
fontFamily: Alf['fonts']['family']
|
||||
} & Pick<Alf, 'flags'>,
|
||||
) {
|
||||
const s = flatten(styles)
|
||||
const s = flatten(styles) ?? {}
|
||||
|
||||
// should always be defined on these components
|
||||
s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
|
||||
|
||||
|
||||
@@ -798,6 +798,7 @@ export function ButtonIcon({
|
||||
* also so that we can calculate transforms.
|
||||
*/
|
||||
const iconSize = {
|
||||
'2xs': 8,
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 18,
|
||||
@@ -842,7 +843,7 @@ export function ButtonIcon({
|
||||
style={[
|
||||
a.z_20,
|
||||
{
|
||||
width: iconContainerSize,
|
||||
width: size === '2xs' ? 10 : iconContainerSize,
|
||||
height: iconContainerSize,
|
||||
marginLeft: iconNegativeMargin,
|
||||
marginRight: iconNegativeMargin,
|
||||
|
||||
@@ -163,7 +163,10 @@ export function MenuButton() {
|
||||
shape="square"
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}>
|
||||
style={[
|
||||
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
|
||||
a.bg_transparent,
|
||||
]}>
|
||||
<ButtonIcon icon={Menu} size="lg" />
|
||||
</Button>
|
||||
</Slot>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type * as HlsTypes from 'hls.js'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
@@ -102,7 +101,6 @@ export function VideoEmbedInnerWeb({
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
/>
|
||||
</div>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
@@ -16,11 +16,9 @@ import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
interface Props {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
}
|
||||
|
||||
export function VideoEmbed({embed, crop}: Props) {
|
||||
const t = useTheme()
|
||||
export function VideoEmbed({embed}: Props) {
|
||||
const [key, setKey] = useState(0)
|
||||
|
||||
const renderError = useCallback(
|
||||
@@ -40,13 +38,10 @@ export function VideoEmbed({embed, crop}: Props) {
|
||||
}
|
||||
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
if (aspectRatio !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
|
||||
}
|
||||
const cropDisabled = crop === 'none'
|
||||
|
||||
const contents = (
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
@@ -56,28 +51,13 @@ export function VideoEmbed({embed, crop}: Props) {
|
||||
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
{cropDisabled ? (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: max ?? 1},
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<ConstrainedImage
|
||||
fullBleed={crop === 'square'}
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
)}
|
||||
<ConstrainedImage
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
@@ -25,13 +26,7 @@ import {
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({
|
||||
embed,
|
||||
crop,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
@@ -76,13 +71,10 @@ export function VideoEmbed({
|
||||
}
|
||||
|
||||
let constrained: number | undefined
|
||||
let max: number | undefined
|
||||
if (aspectRatio !== undefined) {
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
constrained = Math.max(aspectRatio, ratio)
|
||||
max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread
|
||||
}
|
||||
const cropDisabled = crop === 'none'
|
||||
|
||||
const contents = (
|
||||
<div
|
||||
@@ -91,8 +83,11 @@ export function VideoEmbed({
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
cursor: 'default',
|
||||
backgroundColor: t.palette.black,
|
||||
backgroundImage: `url(${embed.thumbnail})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
onClick={evt => evt.stopPropagation()}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
@@ -114,28 +109,15 @@ export function VideoEmbed({
|
||||
<ViewportObserver
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
{cropDisabled ? (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: max ?? 1},
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<ConstrainedImage
|
||||
fullBleed={crop === 'square'}
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
</ConstrainedImage>
|
||||
)}
|
||||
<ConstrainedImage
|
||||
fullBleed
|
||||
aspectRatio={constrained || 1}
|
||||
// slightly smaller max height than images
|
||||
// images use 16 / 9, for reference
|
||||
minMobileAspectRatio={14 / 9}>
|
||||
{contents}
|
||||
<MediaInsetBorder />
|
||||
</ConstrainedImage>
|
||||
</ViewportObserver>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -112,7 +112,7 @@ function MediaEmbed({
|
||||
<ContentHider
|
||||
modui={rest.moderation?.ui('contentMedia')}
|
||||
activeStyle={[a.mt_sm]}>
|
||||
<VideoEmbed embed={embed.view} crop="constrained" />
|
||||
<VideoEmbed embed={embed.view} />
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,11 +48,15 @@ export function Default({
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
testID,
|
||||
position,
|
||||
contextProfileDid,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
testID?: string
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
}) {
|
||||
return (
|
||||
<Link testID={testID} profile={profile}>
|
||||
@@ -60,6 +64,8 @@ export function Default({
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
position={position}
|
||||
contextProfileDid={contextProfileDid}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
@@ -69,10 +75,14 @@ export function Card({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
position,
|
||||
contextProfileDid,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
}) {
|
||||
return (
|
||||
<Outer>
|
||||
@@ -83,6 +93,8 @@ export function Card({
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
position={position}
|
||||
contextProfileDid={contextProfileDid}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
@@ -437,6 +449,8 @@ export type FollowButtonProps = {
|
||||
colorInverted?: boolean
|
||||
onFollow?: () => void
|
||||
withIcon?: boolean
|
||||
position?: number
|
||||
contextProfileDid?: string
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
@@ -453,6 +467,8 @@ export function FollowButtonInner({
|
||||
onFollow,
|
||||
colorInverted,
|
||||
withIcon = true,
|
||||
position,
|
||||
contextProfileDid,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
@@ -461,6 +477,8 @@ export function FollowButtonInner({
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
position,
|
||||
contextProfileDid,
|
||||
)
|
||||
const isRound = Boolean(rest.shape && rest.shape === 'round')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {type TextStyle} from 'react-native'
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
@@ -21,7 +21,7 @@ export type RichTextProps = TextStyleProp &
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
interactiveStyle?: StyleProp<TextStyle>
|
||||
emojiMultiplier?: number
|
||||
shouldProxyLinks?: boolean
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function RichText({
|
||||
|
||||
if (!facets?.length) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const flattenedStyle = flatten(style)
|
||||
const flattenedStyle = flatten(style) ?? {}
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {createContext, forwardRef, useContext, useMemo} from 'react'
|
||||
import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Select as RadixSelect} from 'radix-ui'
|
||||
|
||||
@@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
style={flatten([
|
||||
a.flex,
|
||||
a.relative,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
@@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
a.px_md,
|
||||
a.pointer,
|
||||
{
|
||||
borderRadius: 10,
|
||||
maxWidth: 400,
|
||||
outline: 0,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
borderColor: focused
|
||||
? t.palette.primary_500
|
||||
: hovered
|
||||
? t.palette.contrast_100
|
||||
: t.palette.contrast_25,
|
||||
: t.palette.contrast_50,
|
||||
},
|
||||
])}>
|
||||
{children}
|
||||
@@ -140,7 +138,11 @@ export function Icon({style}: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
export function Content<T>({
|
||||
items,
|
||||
renderItem,
|
||||
valueExtractor = defaultItemValueExtractor,
|
||||
}: ContentProps<T>) {
|
||||
const t = useTheme()
|
||||
const selectedValue = useContext(SelectedValueContext)
|
||||
|
||||
@@ -198,7 +200,11 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
|
||||
</RadixSelect.ScrollUpButton>
|
||||
<RadixSelect.Viewport style={flatten([a.p_xs])}>
|
||||
{items.map((item, index) => renderItem(item, index, selectedValue))}
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={valueExtractor(item)}>
|
||||
{renderItem(item, index, selectedValue)}
|
||||
</Fragment>
|
||||
))}
|
||||
</RadixSelect.Viewport>
|
||||
<RadixSelect.ScrollDownButton style={flatten(down)}>
|
||||
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
|
||||
@@ -209,6 +215,10 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||
)
|
||||
}
|
||||
|
||||
function defaultItemValueExtractor(item: any) {
|
||||
return item.value
|
||||
}
|
||||
|
||||
const ItemContext = createContext<{
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Content() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function TextBubble() {
|
||||
return null
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import {useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
|
||||
import {atoms as a, select, useTheme} from '#/alf'
|
||||
import {useOnGesture} from '#/components/hooks/useOnGesture'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
|
||||
import {
|
||||
ARROW_HALF_SIZE,
|
||||
ARROW_SIZE,
|
||||
@@ -23,6 +25,33 @@ import {
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const TooltipPortal = createPortalGroup()
|
||||
const TooltipProviderContext =
|
||||
createContext<React.RefObject<View | null> | null>(null)
|
||||
|
||||
/**
|
||||
* Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
|
||||
* such as in the composer sheet.
|
||||
*
|
||||
* Only really necessary on iOS but can work on Android.
|
||||
*/
|
||||
export function SheetCompatProvider({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View | null>(null)
|
||||
return (
|
||||
<GlobalGestureEventsProvider style={[a.flex_1]}>
|
||||
<TooltipPortal.Provider>
|
||||
<View ref={ref} collapsable={false} style={[a.flex_1]}>
|
||||
<TooltipProviderContext value={ref}>
|
||||
{children}
|
||||
</TooltipProviderContext>
|
||||
</View>
|
||||
<TooltipPortal.Outlet />
|
||||
</TooltipPortal.Provider>
|
||||
</GlobalGestureEventsProvider>
|
||||
)
|
||||
}
|
||||
SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
|
||||
|
||||
/**
|
||||
* These are native specific values, not shared with web
|
||||
*/
|
||||
@@ -120,22 +149,46 @@ export function Outer({
|
||||
|
||||
export function Target({children}: {children: React.ReactNode}) {
|
||||
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
|
||||
const [hasLayedOut, setHasLayedOut] = useState(false)
|
||||
const targetRef = useRef<View>(null)
|
||||
const containerRef = useContext(TooltipProviderContext)
|
||||
const keyboardIsOpen = useIsKeyboardVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldMeasure) return
|
||||
if (!shouldMeasure || !hasLayedOut) return
|
||||
/*
|
||||
* Once opened, measure the dimensions and position of the target
|
||||
*/
|
||||
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
|
||||
if (pageX !== undefined && pageY !== undefined && width && height) {
|
||||
setTargetMeasurements({x: pageX, y: pageY, width, height})
|
||||
}
|
||||
})
|
||||
}, [shouldMeasure, setTargetMeasurements])
|
||||
|
||||
if (containerRef?.current) {
|
||||
targetRef.current?.measureLayout(
|
||||
containerRef.current,
|
||||
(x, y, width, height) => {
|
||||
if (x !== undefined && y !== undefined && width && height) {
|
||||
setTargetMeasurements({x, y, width, height})
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
targetRef.current?.measure((_x, _y, width, height, x, y) => {
|
||||
if (x !== undefined && y !== undefined && width && height) {
|
||||
setTargetMeasurements({x, y, width, height})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [
|
||||
shouldMeasure,
|
||||
setTargetMeasurements,
|
||||
hasLayedOut,
|
||||
containerRef,
|
||||
keyboardIsOpen,
|
||||
])
|
||||
|
||||
return (
|
||||
<View collapsable={false} ref={targetRef}>
|
||||
<View
|
||||
collapsable={false}
|
||||
ref={targetRef}
|
||||
onLayout={() => setHasLayedOut(true)}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -150,12 +203,15 @@ export function Content({
|
||||
}) {
|
||||
const {position, visible, onVisibleChange} = useContext(TooltipContext)
|
||||
const {targetMeasurements} = useContext(TargetContext)
|
||||
const isWithinProvider = !!useContext(TooltipProviderContext)
|
||||
const requestClose = useCallback(() => {
|
||||
onVisibleChange(false)
|
||||
}, [onVisibleChange])
|
||||
|
||||
if (!visible || !targetMeasurements) return null
|
||||
|
||||
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Bubble
|
||||
|
||||
@@ -11,14 +11,19 @@ import {
|
||||
} from '#/components/Tooltip/const'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
// Portal Provider on native, but we actually don't need to do anything here
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
Provider.displayName = 'TooltipProvider'
|
||||
|
||||
type TooltipContextType = {
|
||||
position: 'top' | 'bottom'
|
||||
onVisibleChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const TooltipContext = createContext<TooltipContextType>({
|
||||
const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
|
||||
position: 'bottom',
|
||||
onVisibleChange: () => {},
|
||||
})
|
||||
TooltipContext.displayName = 'TooltipContext'
|
||||
|
||||
@@ -33,10 +38,7 @@ export function Outer({
|
||||
visible: boolean
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
}) {
|
||||
const ctx = useMemo(
|
||||
() => ({position, onVisibleChange}),
|
||||
[position, onVisibleChange],
|
||||
)
|
||||
const ctx = useMemo(() => ({position}), [position])
|
||||
return (
|
||||
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
|
||||
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
|
||||
@@ -60,7 +62,7 @@ export function Content({
|
||||
label: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {position, onVisibleChange} = useContext(TooltipContext)
|
||||
const {position} = useContext(TooltipContext)
|
||||
return (
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -69,7 +71,11 @@ export function Content({
|
||||
side={position}
|
||||
sideOffset={4}
|
||||
collisionPadding={MIN_EDGE_SPACE}
|
||||
onInteractOutside={() => onVisibleChange(false)}
|
||||
onInteractOutside={evt => {
|
||||
if (evt.type === 'dismissableLayer.focusOutside') {
|
||||
evt.preventDefault()
|
||||
}
|
||||
}}
|
||||
style={flatten([
|
||||
a.rounded_sm,
|
||||
select(t.name, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Fragment, useMemo} from 'react'
|
||||
import {Fragment, useMemo, useRef} from 'react'
|
||||
import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
@@ -17,12 +17,13 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
type ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
@@ -30,13 +31,13 @@ import {
|
||||
PostInteractionSettingsDialog,
|
||||
usePrefetchPostInteractionSettings,
|
||||
} from '#/components/dialogs/PostInteractionSettingsDialog'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign'
|
||||
import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
@@ -69,6 +70,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
postUri: post.uri,
|
||||
rootPostUri: rootUri,
|
||||
})
|
||||
const prefetchPromise = useRef<Promise<void>>(Promise.resolve())
|
||||
|
||||
const prefetch = () => {
|
||||
prefetchPromise.current = prefetchPostInteractionSettings()
|
||||
}
|
||||
|
||||
const anyoneCanReply =
|
||||
settings.length === 1 && settings[0].type === 'everybody'
|
||||
@@ -84,8 +90,19 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
if (isThreadAuthor) {
|
||||
editDialogControl.open()
|
||||
logger.metric('thread:click:editOwnThreadgate', {})
|
||||
|
||||
// wait on prefetch if it manages to resolve in under 200ms
|
||||
// otherwise, proceed immediately and show the spinner -sfn
|
||||
Promise.race([
|
||||
prefetchPromise.current,
|
||||
new Promise(res => setTimeout(res, 200)),
|
||||
]).finally(() => {
|
||||
editDialogControl.open()
|
||||
})
|
||||
} else {
|
||||
logger.metric('thread:click:viewSomeoneElsesThreadgate', {})
|
||||
|
||||
infoDialogControl.open()
|
||||
}
|
||||
}
|
||||
@@ -100,18 +117,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
{...(isThreadAuthor
|
||||
? Platform.select({
|
||||
web: {
|
||||
onHoverIn: prefetchPostInteractionSettings,
|
||||
onHoverIn: prefetch,
|
||||
},
|
||||
native: {
|
||||
onPressIn: prefetchPostInteractionSettings,
|
||||
onPressIn: prefetch,
|
||||
},
|
||||
})
|
||||
: {})}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{({hovered}) => (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
|
||||
{({hovered, focused, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_xs,
|
||||
(hovered || focused || pressed) && native({opacity: 0.5}),
|
||||
style,
|
||||
]}>
|
||||
<Icon
|
||||
color={t.palette.contrast_400}
|
||||
color={
|
||||
isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400
|
||||
}
|
||||
width={16}
|
||||
settings={settings}
|
||||
/>
|
||||
@@ -119,14 +145,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
hovered && a.underline,
|
||||
isThreadAuthor
|
||||
? {color: t.palette.primary_500}
|
||||
: t.atoms.text_contrast_medium,
|
||||
(hovered || focused || pressed) && web(a.underline),
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{isThreadAuthor && (
|
||||
<PencilLine width={12} fill={t.palette.primary_500} />
|
||||
<TinyChevronDownIcon width={8} fill={t.palette.primary_500} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
@@ -164,7 +192,11 @@ function Icon({
|
||||
settings.length === 0 ||
|
||||
settings.every(setting => setting.type === 'everybody')
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
|
||||
const IconComponent = isEverybody
|
||||
? EarthIcon
|
||||
: isNobody
|
||||
? CircleBanSignIcon
|
||||
: GroupIcon
|
||||
return <IconComponent fill={color} width={width} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback} from 'react'
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -27,8 +27,21 @@ export function SubscribeProfileButton({
|
||||
const subscribeDialogControl = useDialogControl()
|
||||
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
|
||||
useActivitySubscriptionsNudged()
|
||||
const [showTooltip, setShowTooltip] = useState(false)
|
||||
|
||||
const onDismissTooltip = () => {
|
||||
useEffect(() => {
|
||||
if (!activitySubscriptionsNudged) {
|
||||
const timeout = setTimeout(() => {
|
||||
setShowTooltip(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [activitySubscriptionsNudged])
|
||||
|
||||
const onDismissTooltip = (visible: boolean) => {
|
||||
if (visible) return
|
||||
|
||||
setShowTooltip(false)
|
||||
setActivitySubscriptionsNudged(true)
|
||||
}
|
||||
|
||||
@@ -56,7 +69,7 @@ export function SubscribeProfileButton({
|
||||
return (
|
||||
<>
|
||||
<Tooltip.Outer
|
||||
visible={!activitySubscriptionsNudged}
|
||||
visible={showTooltip}
|
||||
onVisibleChange={onDismissTooltip}
|
||||
position="bottom">
|
||||
<Tooltip.Target>
|
||||
@@ -65,7 +78,6 @@ export function SubscribeProfileButton({
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Get notified when ${name} posts`)}
|
||||
onPress={wrappedOnPress}>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon,
|
||||
@@ -150,26 +150,27 @@ function EmbedDialogInner({
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}>
|
||||
<Trans>Color theme</Trans>
|
||||
</Text>
|
||||
<ToggleButton.Group
|
||||
<SegmentedControl.Root
|
||||
label={_(msg`Color mode`)}
|
||||
values={[colorMode]}
|
||||
onChange={([value]) => setColorMode(value as ColorModeValues)}>
|
||||
<ToggleButton.Button name="system" label={_(msg`System`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
type="radio"
|
||||
value={colorMode}
|
||||
onChange={setColorMode}>
|
||||
<SegmentedControl.Item value="system" label={_(msg`System`)}>
|
||||
<SegmentedControl.ItemText>
|
||||
<Trans>System</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="light" label={_(msg`Light`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value="light" label={_(msg`Light`)}>
|
||||
<SegmentedControl.ItemText>
|
||||
<Trans>Light</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="dark" label={_(msg`Dark`)}>
|
||||
<ToggleButton.ButtonText>
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item value="dark" label={_(msg`Dark`)}>
|
||||
<SegmentedControl.ItemText>
|
||||
<Trans>Dark</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {useCallback, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, Text as NestedText, View} from 'react-native'
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import isEqual from 'lodash.isequal'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
@@ -37,13 +39,17 @@ import {
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {
|
||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -52,6 +58,10 @@ export type PostInteractionSettingsFormProps = {
|
||||
onSave: () => void
|
||||
isSaving?: boolean
|
||||
|
||||
isDirty?: boolean
|
||||
persist?: boolean
|
||||
onChangePersist?: (v: boolean) => void
|
||||
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
|
||||
|
||||
@@ -61,57 +71,52 @@ export type PostInteractionSettingsFormProps = {
|
||||
replySettingsDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Threadgate settings dialog. Used in the composer.
|
||||
*/
|
||||
export function PostInteractionSettingsControlledDialog({
|
||||
control,
|
||||
...rest
|
||||
}: PostInteractionSettingsFormProps & {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const onClose = useNonReactiveCallback(() => {
|
||||
logger.metric('composer:threadgate:save', {
|
||||
hasChanged: !!rest.isDirty,
|
||||
persist: !!rest.persist,
|
||||
replyOptions:
|
||||
rest.threadgateAllowUISettings?.map(gate => gate.type)?.join(',') ?? '',
|
||||
quotesEnabled: !rest.postgate?.embeddingRules?.find(
|
||||
v => v.$type === embeddingRules.disableRule.$type,
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{
|
||||
preventExpansion: true,
|
||||
preventDismiss: rest.isDirty && rest.persist,
|
||||
}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
<View style={[a.gap_md]}>
|
||||
<Header />
|
||||
<PostInteractionSettingsForm {...rest} />
|
||||
<Text
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
You can set default interaction settings in{' '}
|
||||
<Text style={[a.font_semi_bold, t.atoms.text_contrast_medium]}>
|
||||
Settings → Moderation → Interaction settings
|
||||
</Text>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
<DialogInner {...rest} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md, a.pb_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_semi_bold]}>
|
||||
<Trans>Post interaction settings</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.pb_xs]}>
|
||||
<Trans>Customize who can interact with this post.</Trans>
|
||||
</Text>
|
||||
<Divider />
|
||||
</View>
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
style={[web({maxWidth: 400}), a.w_full]}>
|
||||
<Header />
|
||||
<PostInteractionSettingsForm {...props} />
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,12 +139,17 @@ export type PostInteractionSettingsDialogProps = {
|
||||
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
|
||||
}
|
||||
|
||||
/**
|
||||
* Threadgate settings dialog. Used in the thread.
|
||||
*/
|
||||
export function PostInteractionSettingsDialog(
|
||||
props: PostInteractionSettingsDialogProps,
|
||||
) {
|
||||
const postThreadContext = usePostThreadContext()
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Outer
|
||||
control={props.control}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<PostThreadContextProvider context={postThreadContext}>
|
||||
<PostInteractionSettingsDialogControlledInner {...props} />
|
||||
@@ -153,7 +163,7 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const [isSaving, setIsSaving] = React.useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
|
||||
useThreadgateViewQuery({postUri: props.rootPostUri})
|
||||
@@ -165,28 +175,28 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
|
||||
|
||||
const [editedPostgate, setEditedPostgate] =
|
||||
React.useState<AppBskyFeedPostgate.Record>()
|
||||
useState<AppBskyFeedPostgate.Record>()
|
||||
const [editedAllowUISettings, setEditedAllowUISettings] =
|
||||
React.useState<ThreadgateAllowUISetting[]>()
|
||||
useState<ThreadgateAllowUISetting[]>()
|
||||
|
||||
const isLoading = isLoadingThreadgate || isLoadingPostgate
|
||||
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
|
||||
const isThreadgateOwnedByViewer = React.useMemo(() => {
|
||||
const isThreadgateOwnedByViewer = useMemo(() => {
|
||||
return currentAccount?.did === new AtUri(props.rootPostUri).host
|
||||
}, [props.rootPostUri, currentAccount?.did])
|
||||
|
||||
const postgateValue = React.useMemo(() => {
|
||||
const postgateValue = useMemo(() => {
|
||||
return (
|
||||
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
|
||||
)
|
||||
}, [postgate, editedPostgate, props.postUri])
|
||||
const allowUIValue = React.useMemo(() => {
|
||||
const allowUIValue = useMemo(() => {
|
||||
return (
|
||||
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
|
||||
)
|
||||
}, [threadgateView, editedAllowUISettings])
|
||||
|
||||
const onSave = React.useCallback(async () => {
|
||||
const onSave = useCallback(async () => {
|
||||
if (!editedPostgate && !editedAllowUISettings) {
|
||||
props.control.close()
|
||||
return
|
||||
@@ -248,15 +258,24 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
<View style={[a.gap_md]}>
|
||||
<Header />
|
||||
|
||||
{isLoading ? (
|
||||
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
style={[web({maxWidth: 400}), a.w_full]}>
|
||||
{isLoading ? (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_5xl,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<Loader size="xl" />
|
||||
<Text style={[a.italic, a.text_center]}>
|
||||
<Trans>Loading post interaction settings...</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Header />
|
||||
<PostInteractionSettingsForm
|
||||
replySettingsDisabled={!isThreadgateOwnedByViewer}
|
||||
isSaving={isSaving}
|
||||
@@ -266,8 +285,9 @@ export function PostInteractionSettingsDialogControlledInner(
|
||||
threadgateAllowUISettings={allowUIValue}
|
||||
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -281,11 +301,20 @@ export function PostInteractionSettingsForm({
|
||||
threadgateAllowUISettings,
|
||||
onChangeThreadgateAllowUISettings,
|
||||
replySettingsDisabled,
|
||||
isDirty,
|
||||
persist,
|
||||
onChangePersist,
|
||||
}: PostInteractionSettingsFormProps) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data: lists} = useMyListsQuery('curate')
|
||||
const [quotesEnabled, setQuotesEnabled] = React.useState(
|
||||
const playHaptic = useHaptics()
|
||||
const [showLists, setShowLists] = useState(false)
|
||||
const {
|
||||
data: lists,
|
||||
isPending: isListsPending,
|
||||
isError: isListsError,
|
||||
} = useMyListsQuery('curate')
|
||||
const [quotesEnabled, setQuotesEnabled] = useState(
|
||||
!(
|
||||
postgate.embeddingRules &&
|
||||
postgate.embeddingRules.find(
|
||||
@@ -294,27 +323,7 @@ export function PostInteractionSettingsForm({
|
||||
),
|
||||
)
|
||||
|
||||
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
|
||||
// remove boolean values
|
||||
let newSelected: ThreadgateAllowUISetting[] =
|
||||
threadgateAllowUISettings.filter(
|
||||
v => v.type !== 'nobody' && v.type !== 'everybody',
|
||||
)
|
||||
// toggle
|
||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||
if (i === -1) {
|
||||
newSelected.push(setting)
|
||||
} else {
|
||||
newSelected.splice(i, 1)
|
||||
}
|
||||
if (newSelected.length === 0) {
|
||||
newSelected.push({type: 'everybody'})
|
||||
}
|
||||
|
||||
onChangeThreadgateAllowUISettings(newSelected)
|
||||
}
|
||||
|
||||
const onChangeQuotesEnabled = React.useCallback(
|
||||
const onChangeQuotesEnabled = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setQuotesEnabled(enabled)
|
||||
onChangePostgate(
|
||||
@@ -330,229 +339,346 @@ export function PostInteractionSettingsForm({
|
||||
const noOneCanReply = !!threadgateAllowUISettings.find(
|
||||
v => v.type === 'nobody',
|
||||
)
|
||||
const everyoneCanReply = !!threadgateAllowUISettings.find(
|
||||
v => v.type === 'everybody',
|
||||
)
|
||||
const numberOfListsSelected = threadgateAllowUISettings.filter(
|
||||
v => v.type === 'list',
|
||||
).length
|
||||
|
||||
const toggleGroupValues = useMemo(() => {
|
||||
const values: string[] = []
|
||||
for (const setting of threadgateAllowUISettings) {
|
||||
switch (setting.type) {
|
||||
case 'everybody':
|
||||
case 'nobody':
|
||||
// no granularity, early return with nothing
|
||||
return []
|
||||
case 'followers':
|
||||
values.push('followers')
|
||||
break
|
||||
case 'following':
|
||||
values.push('following')
|
||||
break
|
||||
case 'mention':
|
||||
values.push('mention')
|
||||
break
|
||||
case 'list':
|
||||
values.push(`list:${setting.list}`)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return values
|
||||
}, [threadgateAllowUISettings])
|
||||
|
||||
const toggleGroupOnChange = (values: string[]) => {
|
||||
const settings: ThreadgateAllowUISetting[] = []
|
||||
|
||||
if (values.length === 0) {
|
||||
settings.push({type: 'everybody'})
|
||||
} else {
|
||||
for (const value of values) {
|
||||
if (value.startsWith('list:')) {
|
||||
const listId = value.slice('list:'.length)
|
||||
settings.push({type: 'list', list: listId})
|
||||
} else {
|
||||
settings.push({type: value as 'followers' | 'following' | 'mention'})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onChangeThreadgateAllowUISettings(settings)
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[a.flex_1, a.gap_md]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_semi_bold, a.text_lg]}>
|
||||
<Trans>Quote settings</Trans>
|
||||
</Text>
|
||||
|
||||
<Toggle.Item
|
||||
name="quoteposts"
|
||||
type="checkbox"
|
||||
label={
|
||||
quotesEnabled
|
||||
? _(msg`Click to disable quote posts of this post.`)
|
||||
: _(msg`Click to enable quote posts of this post.`)
|
||||
}
|
||||
value={quotesEnabled}
|
||||
onChange={onChangeQuotesEnabled}
|
||||
style={[a.justify_between, a.pt_xs]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Allow quote posts</Trans>
|
||||
</Text>
|
||||
<Toggle.Switch />
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
{replySettingsDisabled && (
|
||||
<View
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<CircleInfo fill={t.atoms.text_contrast_low.color} />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Reply settings are chosen by the author of the thread
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_1, a.gap_lg]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
{replySettingsDisabled && (
|
||||
<View
|
||||
style={[
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
{
|
||||
opacity: replySettingsDisabled ? 0.3 : 1,
|
||||
},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.font_semi_bold, a.text_lg]}>
|
||||
<Trans>Reply settings</Trans>
|
||||
<CircleInfo fill={t.atoms.text_contrast_low.color} />
|
||||
<Text
|
||||
style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Reply settings are chosen by the author of the thread
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Allow replies from:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Everybody`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
|
||||
}
|
||||
onPress={() =>
|
||||
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
|
||||
}
|
||||
style={{flex: 1}}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Nobody`)}
|
||||
isSelected={noOneCanReply}
|
||||
onPress={() =>
|
||||
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
|
||||
}
|
||||
style={{flex: 1}}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!noOneCanReply && (
|
||||
<>
|
||||
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Or combine these options:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Mentioned users`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'mention',
|
||||
)
|
||||
}
|
||||
onPress={() => onPressAudience({type: 'mention'})}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Users you follow`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'following',
|
||||
)
|
||||
}
|
||||
onPress={() => onPressAudience({type: 'following'})}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Your followers`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'followers',
|
||||
)
|
||||
}
|
||||
onPress={() => onPressAudience({type: 'followers'})}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
{lists && lists.length > 0
|
||||
? lists.map(list => (
|
||||
<Selectable
|
||||
key={list.uri}
|
||||
label={_(msg`Users in "${list.name}"`)}
|
||||
isSelected={
|
||||
!!threadgateAllowUISettings.find(
|
||||
v => v.type === 'list' && v.list === list.uri,
|
||||
)
|
||||
}
|
||||
onPress={() =>
|
||||
onPressAudience({type: 'list', list: list.uri})
|
||||
}
|
||||
disabled={replySettingsDisabled}
|
||||
/>
|
||||
))
|
||||
: // No loading states to avoid jumps for the common case (no lists)
|
||||
null}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}>
|
||||
<Text style={[a.text_md, a.font_medium]}>
|
||||
<Trans>Who can reply</Trans>
|
||||
</Text>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Set who can reply to your post`)}
|
||||
type="radio"
|
||||
maxSelections={1}
|
||||
disabled={replySettingsDisabled}
|
||||
values={
|
||||
everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : []
|
||||
}
|
||||
onChange={val => {
|
||||
if (val.includes('everyone')) {
|
||||
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
|
||||
} else if (val.includes('nobody')) {
|
||||
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
|
||||
} else {
|
||||
onChangeThreadgateAllowUISettings([{type: 'mention'}])
|
||||
}
|
||||
}}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Toggle.Item
|
||||
name="everyone"
|
||||
type="checkbox"
|
||||
label={_(msg`Allow anyone to reply`)}
|
||||
style={[a.flex_1]}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.PanelText>
|
||||
<Trans>Anyone</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="nobody"
|
||||
type="checkbox"
|
||||
label={_(msg`Disable replies entirely`)}
|
||||
style={[a.flex_1]}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.PanelText>
|
||||
<Trans>Nobody</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(
|
||||
msg`Set precisely which groups of people can reply to your post`,
|
||||
)}
|
||||
values={toggleGroupValues}
|
||||
onChange={toggleGroupOnChange}
|
||||
disabled={replySettingsDisabled}>
|
||||
<Toggle.PanelGroup>
|
||||
<Toggle.Item
|
||||
name="followers"
|
||||
type="checkbox"
|
||||
label={_(msg`Allow your followers to reply`)}
|
||||
hitSlop={0}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected} adjacent="trailing">
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.PanelText>
|
||||
<Trans>Your followers</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="following"
|
||||
type="checkbox"
|
||||
label={_(msg`Allow people you follow to reply`)}
|
||||
hitSlop={0}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected} adjacent="both">
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.PanelText>
|
||||
<Trans>People you follow</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="mention"
|
||||
type="checkbox"
|
||||
label={_(msg`Allow people you mention to reply`)}
|
||||
hitSlop={0}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected} adjacent="both">
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.PanelText>
|
||||
<Trans>People you mention</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
|
||||
<Button
|
||||
label={
|
||||
showLists
|
||||
? _(msg`Hide lists`)
|
||||
: _(msg`Show lists of users to select from`)
|
||||
}
|
||||
accessibilityRole="togglebutton"
|
||||
hitSlop={0}
|
||||
onPress={() => {
|
||||
playHaptic('Light')
|
||||
if (isIOS && !showLists) {
|
||||
LayoutAnimation.configureNext({
|
||||
...LayoutAnimation.Presets.linear,
|
||||
duration: 175,
|
||||
})
|
||||
}
|
||||
setShowLists(s => !s)
|
||||
}}>
|
||||
<Toggle.Panel
|
||||
active={numberOfListsSelected > 0}
|
||||
adjacent={showLists ? 'both' : 'leading'}>
|
||||
<Toggle.PanelText>
|
||||
{numberOfListsSelected === 0 ? (
|
||||
<Trans>Select from your lists</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Select from your lists{' '}
|
||||
<NestedText style={[a.font_normal, a.italic]}>
|
||||
<Plural
|
||||
value={numberOfListsSelected}
|
||||
other="(# selected)"
|
||||
/>
|
||||
</NestedText>
|
||||
</Trans>
|
||||
)}
|
||||
</Toggle.PanelText>
|
||||
<Toggle.PanelIcon
|
||||
icon={showLists ? ChevronUpIcon : ChevronDownIcon}
|
||||
/>
|
||||
</Toggle.Panel>
|
||||
</Button>
|
||||
{showLists &&
|
||||
(isListsPending ? (
|
||||
<Toggle.Panel>
|
||||
<Toggle.PanelText>
|
||||
<Trans>Loading lists...</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
) : isListsError ? (
|
||||
<Toggle.Panel>
|
||||
<Toggle.PanelText>
|
||||
<Trans>
|
||||
An error occurred while loading your lists :/
|
||||
</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
) : lists.length === 0 ? (
|
||||
<Toggle.Panel>
|
||||
<Toggle.PanelText>
|
||||
<Trans>You don't have any lists yet.</Trans>
|
||||
</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
) : (
|
||||
lists.map((list, i) => (
|
||||
<Toggle.Item
|
||||
key={list.uri}
|
||||
name={`list:${list.uri}`}
|
||||
type="checkbox"
|
||||
label={_(msg`Allow users in ${list.name} to reply`)}
|
||||
hitSlop={0}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel
|
||||
active={selected}
|
||||
adjacent={
|
||||
i === lists.length - 1 ? 'leading' : 'both'
|
||||
}>
|
||||
<Toggle.Checkbox />
|
||||
<UserAvatar
|
||||
size={24}
|
||||
type="list"
|
||||
avatar={list.avatar}
|
||||
/>
|
||||
<Toggle.PanelText>{list.name}</Toggle.PanelText>
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
))
|
||||
))}
|
||||
</Toggle.PanelGroup>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Toggle.Item
|
||||
name="quoteposts"
|
||||
type="checkbox"
|
||||
label={
|
||||
quotesEnabled
|
||||
? _(msg`Disable quote posts of this post`)
|
||||
: _(msg`Enable quote posts of this post`)
|
||||
}
|
||||
value={quotesEnabled}
|
||||
onChange={onChangeQuotesEnabled}>
|
||||
{({selected}) => (
|
||||
<Toggle.Panel active={selected}>
|
||||
<Toggle.PanelText icon={QuoteIcon}>
|
||||
<Trans>Allow quote posts</Trans>
|
||||
</Toggle.PanelText>
|
||||
<Toggle.Switch />
|
||||
</Toggle.Panel>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
|
||||
{typeof persist !== 'undefined' && (
|
||||
<View style={[{minHeight: 24}, a.justify_center]}>
|
||||
{isDirty ? (
|
||||
<Toggle.Item
|
||||
name="persist"
|
||||
type="checkbox"
|
||||
label={_(msg`Save these options for next time`)}
|
||||
value={persist}
|
||||
onChange={() => onChangePersist?.(!persist)}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText
|
||||
style={[a.text_md, a.font_normal, t.atoms.text]}>
|
||||
<Trans>Save these options for next time</Trans>
|
||||
</Toggle.LabelText>
|
||||
</Toggle.Item>
|
||||
) : (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>These are your default settings</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={!canSave || isSaving}
|
||||
label={_(msg`Save`)}
|
||||
onPress={onSave}
|
||||
color="primary"
|
||||
size="large"
|
||||
variant="solid"
|
||||
style={a.mt_xl}>
|
||||
<ButtonText>{_(msg`Save`)}</ButtonText>
|
||||
{isSaving && <ButtonIcon icon={Loader} position="right" />}
|
||||
size="large">
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
{isSaving && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Selectable({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
style,
|
||||
disabled,
|
||||
}: {
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
function Header() {
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
label={label}
|
||||
accessibilityRole="checkbox"
|
||||
aria-checked={isSelected}
|
||||
accessibilityState={{
|
||||
checked: isSelected,
|
||||
}}
|
||||
style={a.flex_1}>
|
||||
{({hovered, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
{minHeight: 40}, // for consistency with checkmark icon visible or not
|
||||
t.atoms.bg_contrast_50,
|
||||
(hovered || focused) && t.atoms.bg_contrast_100,
|
||||
isSelected && {
|
||||
backgroundColor: t.palette.primary_100,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
<Text style={[a.text_sm, isSelected && a.font_semi_bold]}>
|
||||
{label}
|
||||
</Text>
|
||||
{isSelected ? (
|
||||
<Check size="sm" fill={t.palette.primary_500} />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<View style={[a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Post interaction settings</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -567,7 +693,7 @@ export function usePrefetchPostInteractionSettings({
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
|
||||
return React.useCallback(async () => {
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
|
||||
@@ -5,18 +5,20 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {BSKY_SERVICE} from '#/lib/constants'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {P, Text} from '#/components/Typography'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
|
||||
|
||||
export function ServerInputDialog({
|
||||
control,
|
||||
@@ -29,7 +31,8 @@ export function ServerInputDialog({
|
||||
const formRef = useRef<DialogInnerRef>(null)
|
||||
|
||||
// persist these options between dialog open/close
|
||||
const [fixedOption, setFixedOption] = useState(BSKY_SERVICE)
|
||||
const [fixedOption, setFixedOption] =
|
||||
useState<SegmentedControlOptions>(BSKY_SERVICE)
|
||||
const [previousCustomAddress, setPreviousCustomAddress] = useState('')
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
@@ -40,7 +43,7 @@ export function ServerInputDialog({
|
||||
setPreviousCustomAddress(result)
|
||||
}
|
||||
}
|
||||
logEvent('signin:hostingProviderPressed', {
|
||||
logger.metric('signin:hostingProviderPressed', {
|
||||
hostingProviderDidChange: fixedOption !== BSKY_SERVICE,
|
||||
})
|
||||
}, [onSelect, fixedOption])
|
||||
@@ -49,7 +52,10 @@ export function ServerInputDialog({
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{minHeight: height / 2}}>
|
||||
nativeOptions={platform({
|
||||
android: {minHeight: height / 2},
|
||||
ios: {preventExpansion: true},
|
||||
})}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner
|
||||
formRef={formRef}
|
||||
@@ -70,8 +76,8 @@ function DialogInner({
|
||||
initialCustomAddress,
|
||||
}: {
|
||||
formRef: React.Ref<DialogInnerRef>
|
||||
fixedOption: string
|
||||
setFixedOption: (opt: string) => void
|
||||
fixedOption: SegmentedControlOptions
|
||||
setFixedOption: (opt: SegmentedControlOptions) => void
|
||||
initialCustomAddress: string
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
@@ -124,45 +130,49 @@ function DialogInner({
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityDescribedBy="dialog-description"
|
||||
accessibilityLabelledBy="dialog-title">
|
||||
accessibilityLabelledBy="dialog-title"
|
||||
style={web({maxWidth: 500})}>
|
||||
<View style={[a.relative, a.gap_md, a.w_full]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_semi_bold]}>
|
||||
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose your account provider</Trans>
|
||||
</Text>
|
||||
<ToggleButton.Group
|
||||
label="Preferences"
|
||||
values={[fixedOption]}
|
||||
onChange={values => setFixedOption(values[0])}>
|
||||
<ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
|
||||
<ToggleButton.ButtonText>{_(msg`Bluesky`)}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button
|
||||
<SegmentedControl.Root
|
||||
type="tabs"
|
||||
label={_(msg`Account provider`)}
|
||||
value={fixedOption}
|
||||
onChange={setFixedOption}>
|
||||
<SegmentedControl.Item
|
||||
testID="bskyServiceSelectBtn"
|
||||
value={BSKY_SERVICE}
|
||||
label={_(msg`Bluesky`)}>
|
||||
<SegmentedControl.ItemText>
|
||||
{_(msg`Bluesky`)}
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
<SegmentedControl.Item
|
||||
testID="customSelectBtn"
|
||||
name="custom"
|
||||
value="custom"
|
||||
label={_(msg`Custom`)}>
|
||||
<ToggleButton.ButtonText>{_(msg`Custom`)}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
<SegmentedControl.ItemText>
|
||||
{_(msg`Custom`)}
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
</SegmentedControl.Root>
|
||||
|
||||
{fixedOption === BSKY_SERVICE && isFirstTimeUser && (
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your own provider.
|
||||
If you're new here, we recommend sticking with the default Bluesky
|
||||
Social option.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
<View role="tabpanel">
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
Bluesky is an open network where you can choose your own
|
||||
provider. If you're new here, we recommend sticking with the
|
||||
default Bluesky Social option.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{fixedOption === 'custom' && (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
]}>
|
||||
<View role="tabpanel">
|
||||
<TextField.LabelText nativeID="address-input-label">
|
||||
<Trans>Server address</Trans>
|
||||
</TextField.LabelText>
|
||||
@@ -197,13 +207,8 @@ function DialogInner({
|
||||
)}
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<P
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_1,
|
||||
]}>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.text_sm, a.leading_snug]}>
|
||||
{isFirstTimeUser ? (
|
||||
<Trans>
|
||||
If you're a developer, you can host your own server.
|
||||
@@ -219,18 +224,23 @@ function DialogInner({
|
||||
to="https://atproto.com/guides/self-hosting">
|
||||
<Trans>Learn more.</Trans>
|
||||
</InlineLinkText>
|
||||
</P>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={gtMobile && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
testID="doneBtn"
|
||||
variant="outline"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
size={platform({
|
||||
native: 'large',
|
||||
web: 'small',
|
||||
})}
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Done`)}>
|
||||
<ButtonText>{_(msg`Done`)}</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -73,8 +73,8 @@ export function DateFieldButton({
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderRadius: 10},
|
||||
t.atoms.bg_contrast_50,
|
||||
a.align_center,
|
||||
hovered ? chromeHover : {},
|
||||
focused || pressed ? chromeFocus : {},
|
||||
|
||||
@@ -4,10 +4,10 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {ServerInputDialog} from '#/view/com/auth/server-input'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ServerInputDialog} from '#/components/dialogs/ServerInput'
|
||||
import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
type ButtonProps,
|
||||
ButtonText,
|
||||
type ButtonTextProps,
|
||||
} from '../Button'
|
||||
|
||||
const InternalContext = createContext<{
|
||||
type: 'tabs' | 'radio'
|
||||
size: 'small' | 'large'
|
||||
selectedValue: string
|
||||
selectedPosition: {width: number; x: number} | null
|
||||
onSelectValue: (
|
||||
value: string,
|
||||
position: {width: number; x: number} | null,
|
||||
) => void
|
||||
updatePosition: (position: {width: number; x: number}) => void
|
||||
} | null>(null)
|
||||
|
||||
/**
|
||||
* Segmented control component.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SegmentedControl.Root value={value} onChange={setValue}>
|
||||
* <SegmentedControl.Item value="one">
|
||||
* <SegmentedControl.ItemText value="one">
|
||||
* One
|
||||
* </SegmentedControl.ItemText>
|
||||
* </SegmentedControl.Item>
|
||||
* <SegmentedControl.Item value="two">
|
||||
* <SegmentedControl.ItemText value="two">
|
||||
* Two
|
||||
* </SegmentedControl.ItemText>
|
||||
* </SegmentedControl.Item>
|
||||
* </SegmentedControl.Root>
|
||||
* ```
|
||||
*/
|
||||
export function Root<T extends string>({
|
||||
label,
|
||||
type = 'radio',
|
||||
size = 'large',
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
style,
|
||||
accessibilityHint,
|
||||
}: {
|
||||
label: string
|
||||
type: 'tabs' | 'radio'
|
||||
size?: 'small' | 'large'
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
accessibilityHint?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [selectedPosition, setSelectedPosition] = useState<{
|
||||
width: number
|
||||
x: number
|
||||
} | null>(null)
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return {
|
||||
type,
|
||||
size,
|
||||
selectedValue: value,
|
||||
selectedPosition,
|
||||
onSelectValue: (
|
||||
val: string,
|
||||
position: {width: number; x: number} | null,
|
||||
) => {
|
||||
onChange(val as T)
|
||||
if (position) setSelectedPosition(position)
|
||||
},
|
||||
updatePosition: (position: {width: number; x: number}) => {
|
||||
setSelectedPosition(currPos => {
|
||||
if (
|
||||
currPos &&
|
||||
currPos.width === position.width &&
|
||||
currPos.x === position.x
|
||||
) {
|
||||
return currPos
|
||||
}
|
||||
return position
|
||||
})
|
||||
},
|
||||
}
|
||||
}, [value, selectedPosition, setSelectedPosition, onChange, type, size])
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint ?? ''}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_1,
|
||||
a.relative,
|
||||
a.flex_row,
|
||||
t.atoms.bg_contrast_50,
|
||||
{borderRadius: 14},
|
||||
a.curve_continuous,
|
||||
a.p_xs,
|
||||
style,
|
||||
]}
|
||||
role={type === 'tabs' ? 'tablist' : 'radiogroup'}>
|
||||
{selectedPosition !== null && (
|
||||
<Slider x={selectedPosition.x} width={selectedPosition.width} />
|
||||
)}
|
||||
<InternalContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</InternalContext.Provider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const InternalItemContext = createContext<{
|
||||
active: boolean
|
||||
pressed: boolean
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
} | null>(null)
|
||||
|
||||
export function Item({
|
||||
value,
|
||||
style,
|
||||
children,
|
||||
onPress: onPressProp,
|
||||
...props
|
||||
}: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) {
|
||||
const playHaptic = useHaptics()
|
||||
const [position, setPosition] = useState<{x: number; width: number} | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const ctx = useContext(InternalContext)
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
'SegmentedControl.Item must be used within a SegmentedControl.Root',
|
||||
)
|
||||
|
||||
const active = ctx.selectedValue === value
|
||||
|
||||
// update position if change was external, and not due to onPress
|
||||
const needsUpdate =
|
||||
active &&
|
||||
position &&
|
||||
(ctx.selectedPosition?.x !== position.x ||
|
||||
ctx.selectedPosition?.width !== position.width)
|
||||
|
||||
// can't wait for `useEffectEvent`
|
||||
const update = useNonReactiveCallback(() => {
|
||||
if (position) ctx.updatePosition(position)
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (needsUpdate) {
|
||||
update()
|
||||
}
|
||||
}, [needsUpdate, update])
|
||||
|
||||
const onPress = useCallback(
|
||||
(evt: any) => {
|
||||
playHaptic('Light')
|
||||
ctx.onSelectValue(value, position)
|
||||
onPressProp?.(evt)
|
||||
},
|
||||
[ctx, value, position, onPressProp, playHaptic],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row]}
|
||||
onLayout={evt => {
|
||||
const measuredPosition = {
|
||||
x: evt.nativeEvent.layout.x,
|
||||
width: evt.nativeEvent.layout.width,
|
||||
}
|
||||
if (!ctx.selectedPosition && active) {
|
||||
ctx.onSelectValue(value, measuredPosition)
|
||||
}
|
||||
setPosition(measuredPosition)
|
||||
}}>
|
||||
<Button
|
||||
{...props}
|
||||
onPress={onPress}
|
||||
role={ctx.type === 'tabs' ? 'tab' : 'radio'}
|
||||
accessibilityState={{selected: active}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.bg_transparent,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
{minHeight: ctx.size === 'large' ? 40 : 32},
|
||||
style,
|
||||
]}>
|
||||
{({pressed, hovered, focused}) => (
|
||||
<InternalItemContext.Provider
|
||||
value={{active, pressed, hovered, focused}}>
|
||||
{children}
|
||||
</InternalItemContext.Provider>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({style, ...props}: ButtonTextProps) {
|
||||
const t = useTheme()
|
||||
const ctx = useContext(InternalItemContext)
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
'SegmentedControl.ItemText must be used within a SegmentedControl.Item',
|
||||
)
|
||||
return (
|
||||
<ButtonText
|
||||
{...props}
|
||||
style={[
|
||||
a.text_center,
|
||||
a.text_md,
|
||||
a.font_medium,
|
||||
a.px_xs,
|
||||
ctx.active
|
||||
? t.atoms.text
|
||||
: ctx.focused || ctx.hovered || ctx.pressed
|
||||
? t.atoms.text_contrast_medium
|
||||
: t.atoms.text_contrast_low,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Slider({x, width}: {x: number; width: number}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
layout={native(LinearTransition.easing(Easing.out(Easing.exp)))}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.curve_continuous,
|
||||
t.atoms.bg,
|
||||
{
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: 0,
|
||||
width,
|
||||
borderRadius: 10,
|
||||
},
|
||||
// TODO: new arch supports boxShadow on native
|
||||
// in the meantime this is an attempt to get close
|
||||
platform({
|
||||
web: {
|
||||
boxShadow: '0px 2px 4px 0px #0000000D',
|
||||
},
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
shadowOpacity: 0x0d / 0xff,
|
||||
shadowRadius: 4,
|
||||
},
|
||||
android: {elevation: 0.25},
|
||||
}),
|
||||
platform({
|
||||
native: [{left: x}],
|
||||
web: [{transform: [{translateX: x}]}, a.transition_transform],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -266,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_sm,
|
||||
{borderRadius: 10},
|
||||
t.atoms.bg_contrast_50,
|
||||
{borderColor: 'transparent', borderWidth: 2},
|
||||
ctx.hovered ? chromeHover : {},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
import {View, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const PanelContext = createContext<{active: boolean}>({active: false})
|
||||
|
||||
/**
|
||||
* A nice container for Toggles. See the Threadgate dialog for an example.
|
||||
*/
|
||||
export function Panel({
|
||||
children,
|
||||
active = false,
|
||||
adjacent,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
active?: boolean
|
||||
adjacent?: 'leading' | 'trailing' | 'both'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const leading = adjacent === 'leading' || adjacent === 'both'
|
||||
const trailing = adjacent === 'trailing' || adjacent === 'both'
|
||||
const rounding = {
|
||||
borderTopLeftRadius: leading
|
||||
? tokens.borderRadius.xs
|
||||
: tokens.borderRadius.md,
|
||||
borderTopRightRadius: leading
|
||||
? tokens.borderRadius.xs
|
||||
: tokens.borderRadius.md,
|
||||
borderBottomLeftRadius: trailing
|
||||
? tokens.borderRadius.xs
|
||||
: tokens.borderRadius.md,
|
||||
borderBottomRightRadius: trailing
|
||||
? tokens.borderRadius.xs
|
||||
: tokens.borderRadius.md,
|
||||
} satisfies ViewStyle
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
{minHeight: tokens.space._2xl + tokens.space.md * 2},
|
||||
rounding,
|
||||
active
|
||||
? {backgroundColor: t.palette.primary_50}
|
||||
: t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<PanelContext value={{active}}>{children}</PanelContext>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelText({
|
||||
children,
|
||||
icon,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
icon?: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ctx = useContext(PanelContext)
|
||||
|
||||
const text = (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.flex_1,
|
||||
ctx.active
|
||||
? [a.font_medium, t.atoms.text]
|
||||
: [t.atoms.text_contrast_medium],
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
if (icon) {
|
||||
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||
return (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
||||
<PanelIcon icon={icon} />
|
||||
{text}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
export function PanelIcon({
|
||||
icon: Icon,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const ctx = useContext(PanelContext)
|
||||
return (
|
||||
<Icon
|
||||
style={[
|
||||
ctx.active ? t.atoms.text : t.atoms.text_contrast_medium,
|
||||
a.flex_shrink_0,
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of panels. TODO: auto-leading/trailing
|
||||
*/
|
||||
export function PanelGroup({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.w_full, a.gap_2xs]}>{children}</View>
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
type StyleProp,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {Easing, LinearTransition} from 'react-native-reanimated'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
atoms as a,
|
||||
native,
|
||||
platform,
|
||||
type TextStyleProp,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
@@ -15,6 +23,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export * from './Panel'
|
||||
|
||||
export type ItemState = {
|
||||
name: string
|
||||
selected: boolean
|
||||
@@ -25,7 +35,7 @@ export type ItemState = {
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
const ItemContext = React.createContext<ItemState>({
|
||||
const ItemContext = createContext<ItemState>({
|
||||
name: '',
|
||||
selected: false,
|
||||
disabled: false,
|
||||
@@ -36,7 +46,7 @@ const ItemContext = React.createContext<ItemState>({
|
||||
})
|
||||
ItemContext.displayName = 'ToggleItemContext'
|
||||
|
||||
const GroupContext = React.createContext<{
|
||||
const GroupContext = createContext<{
|
||||
values: string[]
|
||||
disabled: boolean
|
||||
type: 'radio' | 'checkbox'
|
||||
@@ -70,10 +80,11 @@ export type ItemProps = ViewStyleProp & {
|
||||
onChange?: (selected: boolean) => void
|
||||
isInvalid?: boolean
|
||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||
hitSlop?: PressableProps['hitSlop']
|
||||
}
|
||||
|
||||
export function useItemContext() {
|
||||
return React.useContext(ItemContext)
|
||||
return useContext(ItemContext)
|
||||
}
|
||||
|
||||
export function Group({
|
||||
@@ -88,9 +99,8 @@ export function Group({
|
||||
}: GroupProps) {
|
||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||
const [maxReached, setMaxReached] = React.useState(false)
|
||||
|
||||
const setFieldValue = React.useCallback<
|
||||
const setFieldValue = useCallback<
|
||||
(props: {name: string; value: boolean}) => void
|
||||
>(
|
||||
({name, value}) => {
|
||||
@@ -105,25 +115,13 @@ export function Group({
|
||||
[type, onChange, values],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (type === 'checkbox') {
|
||||
if (
|
||||
maxSelections &&
|
||||
values.length >= maxSelections &&
|
||||
maxReached === false
|
||||
) {
|
||||
setMaxReached(true)
|
||||
} else if (
|
||||
maxSelections &&
|
||||
values.length < maxSelections &&
|
||||
maxReached === true
|
||||
) {
|
||||
setMaxReached(false)
|
||||
}
|
||||
}
|
||||
}, [type, values.length, maxSelections, maxReached, setMaxReached])
|
||||
const maxReached = !!(
|
||||
type === 'checkbox' &&
|
||||
maxSelections &&
|
||||
values.length >= maxSelections
|
||||
)
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
values,
|
||||
type,
|
||||
@@ -170,7 +168,7 @@ export function Item({
|
||||
disabled: groupDisabled,
|
||||
setFieldValue,
|
||||
maxSelectionsReached,
|
||||
} = React.useContext(GroupContext)
|
||||
} = useContext(GroupContext)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
@@ -182,19 +180,21 @@ export function Item({
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
const role = groupType === 'radio' ? 'radio' : type
|
||||
const selected = selectedValues.includes(name) || !!value
|
||||
const disabled =
|
||||
groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const onPress = useCallback(() => {
|
||||
playHaptic('Light')
|
||||
const next = !selected
|
||||
setFieldValue({name, value: next})
|
||||
onChange?.(next)
|
||||
}, [name, selected, onChange, setFieldValue])
|
||||
}, [playHaptic, name, selected, onChange, setFieldValue])
|
||||
|
||||
const state = React.useMemo(
|
||||
const state = useMemo(
|
||||
() => ({
|
||||
name,
|
||||
selected,
|
||||
@@ -250,8 +250,8 @@ export function LabelText({
|
||||
style={[
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
a.user_select_none,
|
||||
{
|
||||
userSelect: 'none',
|
||||
color: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_high.color,
|
||||
@@ -287,21 +287,26 @@ export function createSharedToggleStyles({
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.primary_25,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.primary_100,
|
||||
borderColor: t.palette.primary_600,
|
||||
backgroundColor: t.palette.primary_400,
|
||||
borderColor: t.palette.primary_400,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
borderColor: t.palette.contrast_100,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderColor: t.palette.contrast_500,
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -318,6 +323,20 @@ export function createSharedToggleStyles({
|
||||
borderColor: t.palette.negative_600,
|
||||
})
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
borderColor: t.palette.negative_500,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.negative_400,
|
||||
borderColor: t.palette.negative_400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
@@ -325,6 +344,13 @@ export function createSharedToggleStyles({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
borderColor: t.palette.contrast_400,
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.primary_100,
|
||||
borderColor: t.palette.contrast_400,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -350,66 +376,125 @@ export function Checkbox() {
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
t.atoms.border_contrast_high,
|
||||
a.transition_color,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 24,
|
||||
width: 24,
|
||||
borderRadius: 6,
|
||||
},
|
||||
baseStyles,
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||
{selected && <Checkmark width={14} fill={t.palette.white} />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
const {selected, hovered, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => {
|
||||
const base: ViewStyle[] = []
|
||||
const baseHover: ViewStyle[] = []
|
||||
const indicator: ViewStyle[] = []
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.primary_500,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.primary_400,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_200,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvalid) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.negative_200,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.negative_100,
|
||||
})
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor: t.palette.negative_400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.primary_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles: base,
|
||||
baseHoverStyles: disabled ? [] : baseHover,
|
||||
indicatorStyles: indicator,
|
||||
}
|
||||
}, [t, hovered, disabled, selected, isInvalid])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.rounded_full,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_high,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 24,
|
||||
width: 36,
|
||||
height: 28,
|
||||
width: 48,
|
||||
padding: 3,
|
||||
},
|
||||
a.transition_color,
|
||||
baseStyles,
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
<Animated.View
|
||||
layout={LinearTransition.duration(100)}
|
||||
layout={LinearTransition.duration(
|
||||
platform({
|
||||
web: 100,
|
||||
default: 200,
|
||||
}),
|
||||
).easing(Easing.inOut(Easing.cubic))}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 16,
|
||||
width: 16,
|
||||
backgroundColor: t.palette.white,
|
||||
height: 22,
|
||||
width: 22,
|
||||
},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
alignSelf: 'flex-end',
|
||||
}
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_400,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
@@ -420,7 +505,7 @@ export function Switch() {
|
||||
export function Radio() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} =
|
||||
React.useContext(ItemContext)
|
||||
useContext(ItemContext)
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
@@ -437,29 +522,27 @@ export function Radio() {
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
t.atoms.border_contrast_high,
|
||||
a.transition_color,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 24,
|
||||
width: 24,
|
||||
height: 25,
|
||||
width: 25,
|
||||
margin: -1,
|
||||
},
|
||||
baseStyles,
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
{selected && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{height: 16, width: 16},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
}
|
||||
: {},
|
||||
{height: 12, width: 12},
|
||||
{backgroundColor: t.palette.white},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
type AccessibilityProps,
|
||||
type TextStyle,
|
||||
@@ -20,6 +20,9 @@ export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use SegmentedControl
|
||||
*/
|
||||
export function Group({children, multiple, ...props}: GroupProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
@@ -39,6 +42,9 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use SegmentedControl
|
||||
*/
|
||||
export function Button({children, ...props}: ItemProps) {
|
||||
return (
|
||||
<Toggle.Item {...props} style={[a.flex_grow, a.flex_1]}>
|
||||
@@ -51,7 +57,7 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const state = Toggle.useItemContext()
|
||||
|
||||
const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
|
||||
const {baseStyles, hoverStyles, activeStyles} = useMemo(() => {
|
||||
const base: ViewStyle[] = []
|
||||
const hover: ViewStyle[] = []
|
||||
const active: ViewStyle[] = []
|
||||
@@ -112,11 +118,14 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated - use SegmentedControl
|
||||
*/
|
||||
export function ButtonText({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
const state = Toggle.useItemContext()
|
||||
|
||||
const textStyles = React.useMemo(() => {
|
||||
const textStyles = useMemo(() => {
|
||||
const text: TextStyle[] = []
|
||||
if (state.selected) {
|
||||
text.push(t.atoms.text_inverted)
|
||||
|
||||
@@ -19,3 +19,10 @@ export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
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',
|
||||
})
|
||||
|
||||
/**
|
||||
* NOTE: Use with size `2xs`
|
||||
*/
|
||||
export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
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',
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export type Props = {
|
||||
} & Omit<SvgProps, 'style' | 'size'>
|
||||
|
||||
export const sizes = {
|
||||
'2xs': 8,
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
|
||||
@@ -685,7 +685,7 @@ function CategoryCard({
|
||||
}, [onSelect, option])
|
||||
return (
|
||||
<Button
|
||||
testID={`report:option:${option.title}`}
|
||||
testID={`report:category:${option.title}`}
|
||||
label={_(msg`Create report for ${option.title}`)}
|
||||
onPress={onPress}
|
||||
disabled={!onSelect}>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function VerificationsDialog({
|
||||
verificationState: FullVerificationState
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Inner
|
||||
control={control}
|
||||
|
||||
Vendored
+6
-1
@@ -13,7 +13,12 @@ export const RELEASE_VERSION: string =
|
||||
/**
|
||||
* The env the app is running in e.g. development, testflight, production, e2e
|
||||
*/
|
||||
export const ENV: string = process.env.EXPO_PUBLIC_ENV
|
||||
export const ENV: string = process.env.EXPO_PUBLIC_ENV as
|
||||
| 'production'
|
||||
| 'testflight'
|
||||
| 'development'
|
||||
| 'e2e'
|
||||
| (string & {})
|
||||
|
||||
/**
|
||||
* Indicates whether the app is running in TestFlight
|
||||
|
||||
@@ -84,14 +84,14 @@ export async function shareImageModal({uri}: {uri: string}) {
|
||||
return
|
||||
}
|
||||
|
||||
// we're currently relying on the fact our CDN only serves pngs
|
||||
// we're currently relying on the fact our CDN only serves jpegs
|
||||
// -prf
|
||||
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
|
||||
const imagePath = await moveToPermanentPath(imageUri, '.png')
|
||||
const imageUri = await downloadImage(uri, createPath('jpg'), 15e3)
|
||||
const imagePath = await moveToPermanentPath(imageUri, '.jpg')
|
||||
safeDeleteAsync(imageUri)
|
||||
await Sharing.shareAsync(imagePath, {
|
||||
mimeType: 'image/png',
|
||||
UTI: 'image/png',
|
||||
mimeType: 'image/jpeg',
|
||||
UTI: 'image/jpeg',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,11 +100,11 @@ const ALBUM_NAME = 'Bluesky'
|
||||
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
|
||||
// download the file to cache
|
||||
// NOTE
|
||||
// assuming PNG
|
||||
// we're currently relying on the fact our CDN only serves pngs
|
||||
// assuming JPEG
|
||||
// we're currently relying on the fact our CDN only serves jpegs
|
||||
// -prf
|
||||
const imageUri = await downloadImage(uri, createPath('png'), 5e3)
|
||||
const imagePath = await moveToPermanentPath(imageUri, '.png')
|
||||
const imageUri = await downloadImage(uri, createPath('jpg'), 15e3)
|
||||
const imagePath = await moveToPermanentPath(imageUri, '.jpg')
|
||||
|
||||
// save
|
||||
try {
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function openCropper(opts: OpenCropperOptions) {
|
||||
|
||||
return {
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
mime: item.mimeType,
|
||||
size: item.size,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||
import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker'
|
||||
import {t} from '@lingui/macro'
|
||||
|
||||
export {
|
||||
openPicker,
|
||||
@@ -31,13 +32,15 @@ export async function openCamera(customOpts: ImagePickerOptions) {
|
||||
|
||||
export async function openCropper(opts: OpenCropperOptions) {
|
||||
const item = await ExpoImageCropTool.openCropperAsync({
|
||||
doneButtonText: t`Done`,
|
||||
cancelButtonText: t`Cancel`,
|
||||
...opts,
|
||||
format: 'jpeg',
|
||||
})
|
||||
|
||||
return {
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
mime: item.mimeType,
|
||||
size: item.size,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {type I18n} from '@lingui/core'
|
||||
|
||||
export function niceDate(i18n: I18n, date: number | string | Date) {
|
||||
export function niceDate(
|
||||
i18n: I18n,
|
||||
date: number | string | Date,
|
||||
dateStyle: 'short' | 'medium' | 'long' | 'full' = 'long',
|
||||
) {
|
||||
const d = new Date(date)
|
||||
|
||||
return i18n.date(d, {
|
||||
dateStyle: 'long',
|
||||
dateStyle,
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
+994
-909
File diff suppressed because it is too large
Load Diff
+877
-797
File diff suppressed because it is too large
Load Diff
+993
-908
File diff suppressed because it is too large
Load Diff
+877
-797
File diff suppressed because it is too large
Load Diff
+877
-797
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+996
-911
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+354
-289
File diff suppressed because it is too large
Load Diff
+1001
-916
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+998
-913
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+877
-797
File diff suppressed because it is too large
Load Diff
+993
-908
File diff suppressed because it is too large
Load Diff
+1038
-953
File diff suppressed because it is too large
Load Diff
+877
-797
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+1007
-922
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+998
-913
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+1015
-930
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+994
-909
File diff suppressed because it is too large
Load Diff
+1001
-916
File diff suppressed because it is too large
Load Diff
+1002
-917
File diff suppressed because it is too large
Load Diff
+1027
-942
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,16 @@ export type MetricEvents = {
|
||||
'composer:gif:select': {}
|
||||
'postComposer:click': {}
|
||||
|
||||
'composer:threadgate:open': {
|
||||
nudged: boolean
|
||||
}
|
||||
'composer:threadgate:save': {
|
||||
replyOptions: string
|
||||
quotesEnabled: boolean
|
||||
persist: boolean
|
||||
hasChanged: boolean
|
||||
}
|
||||
|
||||
// Data events
|
||||
'account:create:begin': {}
|
||||
'account:create:success': {
|
||||
@@ -257,9 +267,12 @@ export type MetricEvents = {
|
||||
'bookmarks:view': {}
|
||||
'bookmarks:post-clicked': {}
|
||||
'profile:follow': {
|
||||
contextProfileDid?: string
|
||||
didBecomeMutual: boolean | undefined
|
||||
followeeClout: number | undefined
|
||||
followeeDid: string
|
||||
followerClout: number | undefined
|
||||
position?: number
|
||||
logContext:
|
||||
| 'RecommendedFollowsItem'
|
||||
| 'PostThreadItem'
|
||||
@@ -277,6 +290,11 @@ export type MetricEvents = {
|
||||
| 'ExploreSuggestedAccounts'
|
||||
| 'OnboardingSuggestedAccounts'
|
||||
}
|
||||
'profileCard:seen': {
|
||||
contextProfileDid?: string
|
||||
profileDid: string
|
||||
position?: number
|
||||
}
|
||||
'suggestedUser:follow': {
|
||||
logContext:
|
||||
| 'Explore'
|
||||
@@ -374,6 +392,10 @@ export type MetricEvents = {
|
||||
}
|
||||
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
'desktopFeeds:feed:click': {
|
||||
feedUri: string
|
||||
feedDescriptor: string
|
||||
}
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
'profile:addToStarterPack': {}
|
||||
@@ -503,6 +525,8 @@ export type MetricEvents = {
|
||||
[key: string]: any
|
||||
}
|
||||
'thread:click:headerMenuOpen': {}
|
||||
'thread:click:editOwnThreadgate': {}
|
||||
'thread:click:viewSomeoneElsesThreadgate': {}
|
||||
'activitySubscription:enable': {
|
||||
setting: 'posts' | 'posts_and_replies'
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {createFullHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {FormError} from '#/components/forms/FormError'
|
||||
import {HostingProvider} from '#/components/forms/HostingProvider'
|
||||
@@ -69,7 +70,9 @@ export const LoginForm = ({
|
||||
const identifierValueRef = useRef<string>(initialHandle || '')
|
||||
const passwordValueRef = useRef<string>('')
|
||||
const authFactorTokenValueRef = useRef<string>('')
|
||||
const identifierRef = useRef<TextInput>(null)
|
||||
const passwordRef = useRef<TextInput>(null)
|
||||
const hasFocusedOnce = useRef<boolean>(false)
|
||||
const {_} = useLingui()
|
||||
const {login} = useSessionApi()
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
@@ -198,9 +201,10 @@ export const LoginForm = ({
|
||||
<TextField.Icon icon={At} />
|
||||
<TextField.Input
|
||||
testID="loginUsernameInput"
|
||||
inputRef={identifierRef}
|
||||
label={_(msg`Username or email address`)}
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
autoFocus={!isIOS}
|
||||
autoCorrect={false}
|
||||
autoComplete="username"
|
||||
returnKeyType="next"
|
||||
@@ -228,11 +232,10 @@ export const LoginForm = ({
|
||||
label={_(msg`Password`)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
autoComplete="current-password"
|
||||
returnKeyType="done"
|
||||
enablesReturnKeyAutomatically={true}
|
||||
secureTextEntry={true}
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
onChangeText={v => {
|
||||
passwordValueRef.current = v
|
||||
@@ -241,6 +244,16 @@ export const LoginForm = ({
|
||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||
editable={!isProcessing}
|
||||
accessibilityHint={_(msg`Enter your password`)}
|
||||
onLayout={ios(() => {
|
||||
if (hasFocusedOnce.current) return
|
||||
hasFocusedOnce.current = true
|
||||
// kinda dumb, but if we use `autoFocus` to focus
|
||||
// the username input, it happens before the password
|
||||
// input gets rendered. this breaks the password autofill
|
||||
// on iOS (it only does the username part). delaying
|
||||
// it until both inputs are rendered fixes the autofill -sfn
|
||||
identifierRef.current?.focus()
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
testID="forgotPasswordButton"
|
||||
|
||||
@@ -147,10 +147,10 @@ export const SetNewPasswordForm = ({
|
||||
label={_(msg`Enter a password`)}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="password"
|
||||
returnKeyType="done"
|
||||
secureTextEntry={true}
|
||||
textContentType="password"
|
||||
autoComplete="new-password"
|
||||
passwordRules="minlength: 8;"
|
||||
clearButtonMode="while-editing"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
|
||||
@@ -570,7 +570,7 @@ function ExpandedPostDetails({
|
||||
<BackdatedPostIndicator post={post} />
|
||||
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{niceDate(i18n, post.indexedAt)}
|
||||
{niceDate(i18n, post.indexedAt, 'medium')}
|
||||
</Text>
|
||||
{isRootPost && (
|
||||
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
|
||||
import {atoms as a, native, useAlf, useTheme} from '#/alf'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||
@@ -36,42 +36,29 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||
|
||||
const onChangeAppearance = useCallback(
|
||||
(keys: string[]) => {
|
||||
const appearance = keys.find(key => key !== colorMode) as
|
||||
| 'system'
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| undefined
|
||||
if (!appearance) return
|
||||
setColorMode(appearance)
|
||||
(value: 'light' | 'system' | 'dark') => {
|
||||
setColorMode(value)
|
||||
},
|
||||
[setColorMode, colorMode],
|
||||
[setColorMode],
|
||||
)
|
||||
|
||||
const onChangeDarkTheme = useCallback(
|
||||
(keys: string[]) => {
|
||||
const theme = keys.find(key => key !== darkTheme) as
|
||||
| 'dim'
|
||||
| 'dark'
|
||||
| undefined
|
||||
if (!theme) return
|
||||
setDarkTheme(theme)
|
||||
(value: 'dim' | 'dark') => {
|
||||
setDarkTheme(value)
|
||||
},
|
||||
[setDarkTheme, darkTheme],
|
||||
[setDarkTheme],
|
||||
)
|
||||
|
||||
const onChangeFontFamily = useCallback(
|
||||
(values: string[]) => {
|
||||
const next = values[0] === 'system' ? 'system' : 'theme'
|
||||
fonts.setFontFamily(next)
|
||||
(value: 'system' | 'theme') => {
|
||||
fonts.setFontFamily(value)
|
||||
},
|
||||
[fonts],
|
||||
)
|
||||
|
||||
const onChangeFontScale = useCallback(
|
||||
(values: string[]) => {
|
||||
const next = values[0] || ('0' as any)
|
||||
fonts.setFontScale(next)
|
||||
(value: Alf['fonts']['scale']) => {
|
||||
fonts.setFontScale(value)
|
||||
},
|
||||
[fonts],
|
||||
)
|
||||
@@ -107,7 +94,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
name: 'dark',
|
||||
},
|
||||
]}
|
||||
values={[colorMode]}
|
||||
value={colorMode}
|
||||
onChange={onChangeAppearance}
|
||||
/>
|
||||
|
||||
@@ -128,7 +115,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
name: 'dark',
|
||||
},
|
||||
]}
|
||||
values={[darkTheme ?? 'dim']}
|
||||
value={darkTheme ?? 'dim'}
|
||||
onChange={onChangeDarkTheme}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -153,7 +140,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
name: 'theme',
|
||||
},
|
||||
]}
|
||||
values={[fonts.family]}
|
||||
value={fonts.family}
|
||||
onChange={onChangeFontFamily}
|
||||
/>
|
||||
|
||||
@@ -174,7 +161,7 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
name: '1',
|
||||
},
|
||||
]}
|
||||
values={[fonts.scale]}
|
||||
value={fonts.scale}
|
||||
onChange={onChangeFontScale}
|
||||
/>
|
||||
|
||||
@@ -192,12 +179,12 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceToggleButtonGroup({
|
||||
export function AppearanceToggleButtonGroup<T extends string>({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
items,
|
||||
values,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
@@ -205,10 +192,10 @@ export function AppearanceToggleButtonGroup({
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
items: {
|
||||
label: string
|
||||
name: string
|
||||
name: T
|
||||
}[]
|
||||
values: string[]
|
||||
onChange: (values: string[]) => void
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
@@ -227,16 +214,22 @@ export function AppearanceToggleButtonGroup({
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
<ToggleButton.Group label={title} values={values} onChange={onChange}>
|
||||
<SegmentedControl.Root
|
||||
type="radio"
|
||||
label={title}
|
||||
value={value}
|
||||
onChange={onChange}>
|
||||
{items.map(item => (
|
||||
<ToggleButton.Button
|
||||
<SegmentedControl.Item
|
||||
key={item.name}
|
||||
label={item.label}
|
||||
name={item.name}>
|
||||
<ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
value={item.name}>
|
||||
<SegmentedControl.ItemText>
|
||||
{item.label}
|
||||
</SegmentedControl.ItemText>
|
||||
</SegmentedControl.Item>
|
||||
))}
|
||||
</ToggleButton.Group>
|
||||
</SegmentedControl.Root>
|
||||
</SettingsList.Group>
|
||||
</>
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user