Merge branch 'main' into composer-prompt

This commit is contained in:
Alex Benzer
2025-11-18 16:14:02 -08:00
125 changed files with 46624 additions and 41544 deletions
+1
View File
@@ -43,6 +43,7 @@ module.exports = {
suggestedTextWrappers: {
Button: 'ButtonText',
'ToggleButton.Button': 'ToggleButton.ButtonText',
'SegmentedControl.Item': 'SegmentedControl.ItemText',
},
},
],
+1 -1
View File
@@ -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: >
+8 -2
View File
@@ -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 -3
View File
@@ -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"
+2 -1
View File
@@ -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:
+5 -3
View File
@@ -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 -2
View File
@@ -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

@@ -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
}
}
}
+2 -2
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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,
+4 -1
View File
@@ -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>
)
}
+9 -29
View File
@@ -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>
)
+1 -1
View File
@@ -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>
)
}
+18
View File
@@ -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')
+3 -3
View File
@@ -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 (
+18 -8
View File
@@ -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
+19
View File
@@ -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
}
+65 -9
View File
@@ -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
+14 -8
View File
@@ -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, {
+48 -16
View File
@@ -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}>
+18 -17
View File
@@ -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 &rarr; Moderation &rarr; 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 : {},
+1 -1
View File
@@ -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'
+287
View File
@@ -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],
}),
]}
/>
)
}
+1 -1
View File
@@ -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 : {},
+120
View File
@@ -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>
)
}
+12 -3
View File
@@ -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)
+7
View File
@@ -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',
})
+1
View File
@@ -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}
+6 -1
View File
@@ -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
+9 -9
View File
@@ -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 {
+1 -1
View File
@@ -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,
+4 -1
View File
@@ -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,
+6 -2
View File
@@ -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',
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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'
}
+17 -4
View File
@@ -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"
+2 -2
View File
@@ -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} />
+33 -40
View File
@@ -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