[Threadgate] Tweak threadgate buttons (#9173)

* tweak in-post threadgate button

* tweak composer threadgate button

* reduce date length slightly

* pressed styles

* make date length depend on breakpoint

* add chevron to label btn

* add tiny chevron, special-case button icon width

* [Threadgate] Add hint (#9350)

* get tooltip working on web

* add compatibility layer for working in iOS sheets

* add timeout to profile tooltip now that it appears instantly

* rm debug code

* Update ThreadgateBtn.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* remeasure when keyboard changes

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix types

* [Threadgate] Refresh dialog (#9342)

* wip new ui

* update threadgate dialog with new designs

* restore nobody option

* relayout android sheet when ratio changes

* fix ratio changing case in bottom sheet

* timebox reached, use setTimeout

* update panel styles

* missing imports

* extract out Panel

* tweak layout animation

* fix icon color

* use same color mechamism for icon as text

* restore the header

* refreshed toggle styles (#9343)

* [Threadgate] Persist settings (#9341)

* add persist toggle to threadgate dialog

* move state back down

* sort out spacing

* wire up query

* @surfdude29 tweaks

* use tiny chevron in WhoCanReply

* wait for prefetch before opening

* move Panel into the Toggle namespace

* default -> pref

* use medium date length

* rm hover state from web selects, fix border radius

* fix key issue in Selects

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
This commit is contained in:
Samuel Newman
2025-11-14 16:21:37 +02:00
committed by GitHub
parent b56ee74cdb
commit b422765aed
27 changed files with 1057 additions and 468 deletions
@@ -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) val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let { bottomSheet?.let {
val behavior = BottomSheetBehavior.from(it) 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) { if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
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 {BottomSheet} from './src/BottomSheet'
import { import {
BottomSheetSnapPoint, BottomSheetSnapPoint,
BottomSheetState, type BottomSheetState,
BottomSheetViewProps, type BottomSheetViewProps,
} from './src/BottomSheet.types' } from './src/BottomSheet.types'
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
import { import {
@@ -112,9 +112,21 @@ export class BottomSheetNativeComponent extends React.Component<
onStateChange={this.onStateChange} onStateChange={this.onStateChange}
extraStyles={extraStyles} extraStyles={extraStyles}
onLayout={e => { onLayout={e => {
if (isIOS15) {
const {height} = e.nativeEvent.layout const {height} = e.nativeEvent.layout
this.setState({viewHeight: height}) 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() this.updateLayout()
}
}} }}
/> />
</Portal> </Portal>
+2 -1
View File
@@ -798,6 +798,7 @@ export function ButtonIcon({
* also so that we can calculate transforms. * also so that we can calculate transforms.
*/ */
const iconSize = { const iconSize = {
'2xs': 8,
xs: 12, xs: 12,
sm: 16, sm: 16,
md: 18, md: 18,
@@ -842,7 +843,7 @@ export function ButtonIcon({
style={[ style={[
a.z_20, a.z_20,
{ {
width: iconContainerSize, width: size === '2xs' ? 10 : iconContainerSize,
height: iconContainerSize, height: iconContainerSize,
marginLeft: iconNegativeMargin, marginLeft: iconNegativeMargin,
marginRight: iconNegativeMargin, marginRight: iconNegativeMargin,
+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 {View} from 'react-native'
import {Select as RadixSelect} from 'radix-ui' import {Select as RadixSelect} from 'radix-ui'
@@ -96,8 +96,7 @@ export function Trigger({children, label}: TriggerProps) {
style={flatten([ style={flatten([
a.flex, a.flex,
a.relative, a.relative,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_50,
a.rounded_sm,
a.w_full, a.w_full,
a.align_center, a.align_center,
a.gap_sm, a.gap_sm,
@@ -106,15 +105,14 @@ export function Trigger({children, label}: TriggerProps) {
a.px_md, a.px_md,
a.pointer, a.pointer,
{ {
borderRadius: 10,
maxWidth: 400, maxWidth: 400,
outline: 0, outline: 0,
borderWidth: 2, borderWidth: 2,
borderStyle: 'solid', borderStyle: 'solid',
borderColor: focused borderColor: focused
? t.palette.primary_500 ? t.palette.primary_500
: hovered : t.palette.contrast_50,
? t.palette.contrast_100
: t.palette.contrast_25,
}, },
])}> ])}>
{children} {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 t = useTheme()
const selectedValue = useContext(SelectedValueContext) const selectedValue = useContext(SelectedValueContext)
@@ -198,7 +200,11 @@ export function Content<T>({items, renderItem}: ContentProps<T>) {
<ChevronUpIcon style={[t.atoms.text]} size="xs" /> <ChevronUpIcon style={[t.atoms.text]} size="xs" />
</RadixSelect.ScrollUpButton> </RadixSelect.ScrollUpButton>
<RadixSelect.Viewport style={flatten([a.p_xs])}> <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.Viewport>
<RadixSelect.ScrollDownButton style={flatten(down)}> <RadixSelect.ScrollDownButton style={flatten(down)}>
<ChevronDownIcon style={[t.atoms.text]} size="xs" /> <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<{ const ItemContext = createContext<{
hovered: boolean hovered: boolean
focused: boolean focused: boolean
+63 -7
View File
@@ -12,9 +12,11 @@ import {useWindowDimensions, View} from 'react-native'
import Animated, {Easing, ZoomIn} from 'react-native-reanimated' import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' 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 {atoms as a, select, useTheme} from '#/alf'
import {useOnGesture} from '#/components/hooks/useOnGesture' import {useOnGesture} from '#/components/hooks/useOnGesture'
import {Portal} from '#/components/Portal' import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
import { import {
ARROW_HALF_SIZE, ARROW_HALF_SIZE,
ARROW_SIZE, ARROW_SIZE,
@@ -23,6 +25,33 @@ import {
} from '#/components/Tooltip/const' } from '#/components/Tooltip/const'
import {Text} from '#/components/Typography' 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 * These are native specific values, not shared with web
*/ */
@@ -120,22 +149,46 @@ export function Outer({
export function Target({children}: {children: React.ReactNode}) { export function Target({children}: {children: React.ReactNode}) {
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const [hasLayedOut, setHasLayedOut] = useState(false)
const targetRef = useRef<View>(null) const targetRef = useRef<View>(null)
const containerRef = useContext(TooltipProviderContext)
const keyboardIsOpen = useIsKeyboardVisible()
useEffect(() => { useEffect(() => {
if (!shouldMeasure) return if (!shouldMeasure || !hasLayedOut) return
/* /*
* Once opened, measure the dimensions and position of the target * 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) { if (containerRef?.current) {
setTargetMeasurements({x: pageX, y: pageY, width, height}) 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]) }
}, [
shouldMeasure,
setTargetMeasurements,
hasLayedOut,
containerRef,
keyboardIsOpen,
])
return ( return (
<View collapsable={false} ref={targetRef}> <View
collapsable={false}
ref={targetRef}
onLayout={() => setHasLayedOut(true)}>
{children} {children}
</View> </View>
) )
@@ -150,12 +203,15 @@ export function Content({
}) { }) {
const {position, visible, onVisibleChange} = useContext(TooltipContext) const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext) const {targetMeasurements} = useContext(TargetContext)
const isWithinProvider = !!useContext(TooltipProviderContext)
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
onVisibleChange(false) onVisibleChange(false)
}, [onVisibleChange]) }, [onVisibleChange])
if (!visible || !targetMeasurements) return null if (!visible || !targetMeasurements) return null
const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
return ( return (
<Portal> <Portal>
<Bubble <Bubble
+14 -8
View File
@@ -11,14 +11,19 @@ import {
} from '#/components/Tooltip/const' } from '#/components/Tooltip/const'
import {Text} from '#/components/Typography' 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 = { type TooltipContextType = {
position: 'top' | 'bottom' position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void onVisibleChange: (open: boolean) => void
} }
const TooltipContext = createContext<TooltipContextType>({ const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({
position: 'bottom', position: 'bottom',
onVisibleChange: () => {},
}) })
TooltipContext.displayName = 'TooltipContext' TooltipContext.displayName = 'TooltipContext'
@@ -33,10 +38,7 @@ export function Outer({
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
const ctx = useMemo( const ctx = useMemo(() => ({position}), [position])
() => ({position, onVisibleChange}),
[position, onVisibleChange],
)
return ( return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}> <Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
@@ -60,7 +62,7 @@ export function Content({
label: string label: string
}) { }) {
const t = useTheme() const t = useTheme()
const {position, onVisibleChange} = useContext(TooltipContext) const {position} = useContext(TooltipContext)
return ( return (
<Popover.Portal> <Popover.Portal>
<Popover.Content <Popover.Content
@@ -69,7 +71,11 @@ export function Content({
side={position} side={position}
sideOffset={4} sideOffset={4}
collisionPadding={MIN_EDGE_SPACE} collisionPadding={MIN_EDGE_SPACE}
onInteractOutside={() => onVisibleChange(false)} onInteractOutside={evt => {
if (evt.type === 'dismissableLayer.focusOutside') {
evt.preventDefault()
}
}}
style={flatten([ style={flatten([
a.rounded_sm, a.rounded_sm,
select(t.name, { select(t.name, {
+42 -15
View File
@@ -1,4 +1,4 @@
import {Fragment, useMemo} from 'react' import {Fragment, useMemo, useRef} from 'react'
import { import {
Keyboard, Keyboard,
Platform, Platform,
@@ -22,7 +22,7 @@ import {
type ThreadgateAllowUISetting, type ThreadgateAllowUISetting,
threadgateViewToAllowUISetting, threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate' } 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 {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
@@ -30,13 +30,13 @@ import {
PostInteractionSettingsDialog, PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings, usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog' } from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 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 {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps { interface WhoCanReplyProps {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
@@ -69,6 +69,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
postUri: post.uri, postUri: post.uri,
rootPostUri: rootUri, rootPostUri: rootUri,
}) })
const prefetchPromise = useRef<Promise<void>>(Promise.resolve())
const prefetch = () => {
prefetchPromise.current = prefetchPostInteractionSettings()
}
const anyoneCanReply = const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody' settings.length === 1 && settings[0].type === 'everybody'
@@ -84,7 +89,14 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
Keyboard.dismiss() Keyboard.dismiss()
} }
if (isThreadAuthor) { if (isThreadAuthor) {
// 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() editDialogControl.open()
})
} else { } else {
infoDialogControl.open() infoDialogControl.open()
} }
@@ -100,18 +112,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
{...(isThreadAuthor {...(isThreadAuthor
? Platform.select({ ? Platform.select({
web: { web: {
onHoverIn: prefetchPostInteractionSettings, onHoverIn: prefetch,
}, },
native: { native: {
onPressIn: prefetchPostInteractionSettings, onPressIn: prefetch,
}, },
}) })
: {})} : {})}
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
{({hovered}) => ( {({hovered, focused, pressed}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}> <View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
(hovered || focused || pressed) && native({opacity: 0.5}),
style,
]}>
<Icon <Icon
color={t.palette.contrast_400} color={
isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400
}
width={16} width={16}
settings={settings} settings={settings}
/> />
@@ -119,14 +140,16 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
style={[ style={[
a.text_sm, a.text_sm,
a.leading_tight, a.leading_tight,
t.atoms.text_contrast_medium, isThreadAuthor
hovered && a.underline, ? {color: t.palette.primary_500}
: t.atoms.text_contrast_medium,
(hovered || focused || pressed) && web(a.underline),
]}> ]}>
{description} {description}
</Text> </Text>
{isThreadAuthor && ( {isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} /> <TinyChevronDownIcon width={8} fill={t.palette.primary_500} />
)} )}
</View> </View>
)} )}
@@ -164,7 +187,11 @@ function Icon({
settings.length === 0 || settings.length === 0 ||
settings.every(setting => setting.type === 'everybody') settings.every(setting => setting.type === 'everybody')
const isNobody = !!settings.find(gate => gate.type === 'nobody') 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} /> 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 {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -27,8 +27,21 @@ export function SubscribeProfileButton({
const subscribeDialogControl = useDialogControl() const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
useActivitySubscriptionsNudged() 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) setActivitySubscriptionsNudged(true)
} }
@@ -56,7 +69,7 @@ export function SubscribeProfileButton({
return ( return (
<> <>
<Tooltip.Outer <Tooltip.Outer
visible={!activitySubscriptionsNudged} visible={showTooltip}
onVisibleChange={onDismissTooltip} onVisibleChange={onDismissTooltip}
position="bottom"> position="bottom">
<Tooltip.Target> <Tooltip.Target>
@@ -65,7 +78,6 @@ export function SubscribeProfileButton({
testID="dmBtn" testID="dmBtn"
size="small" size="small"
color="secondary" color="secondary"
variant="solid"
shape="round" shape="round"
label={_(msg`Get notified when ${name} posts`)} label={_(msg`Get notified when ${name} posts`)}
onPress={wrappedOnPress}> onPress={wrappedOnPress}>
@@ -1,16 +1,17 @@
import React from 'react' import {useCallback, useMemo, useState} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native' import {LayoutAnimation, Text as NestedText, View} from 'react-native'
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyFeedPostgate, type AppBskyFeedPostgate,
AtUri, AtUri,
} from '@atproto/api' } from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {useHaptics} from '#/lib/haptics'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists' import {useMyListsQuery} from '#/state/queries/my-lists'
import {useGetPost} from '#/state/queries/post' import {useGetPost} from '#/state/queries/post'
@@ -37,13 +38,17 @@ import {
} from '#/state/queries/usePostThread' } from '#/state/queries/usePostThread'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle' 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 {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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -52,6 +57,10 @@ export type PostInteractionSettingsFormProps = {
onSave: () => void onSave: () => void
isSaving?: boolean isSaving?: boolean
isDirty?: boolean
persist?: boolean
onChangePersist?: (v: boolean) => void
postgate: AppBskyFeedPostgate.Record postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
@@ -61,57 +70,39 @@ export type PostInteractionSettingsFormProps = {
replySettingsDisabled?: boolean replySettingsDisabled?: boolean
} }
/**
* Threadgate settings dialog. Used in the composer.
*/
export function PostInteractionSettingsControlledDialog({ export function PostInteractionSettingsControlledDialog({
control, control,
...rest ...rest
}: PostInteractionSettingsFormProps & { }: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
}) { }) {
const t = useTheme()
const {_} = useLingui()
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer
control={control}
nativeOptions={{
preventExpansion: true,
preventDismiss: rest.isDirty && rest.persist,
}}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.ScrollableInner <DialogInner {...rest} />
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>
</Dialog.Outer> </Dialog.Outer>
) )
} }
export function Header() { function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) {
const {_} = useLingui()
return ( return (
<View style={[a.gap_md, a.pb_sm]}> <Dialog.ScrollableInner
<Text style={[a.text_2xl, a.font_semi_bold]}> label={_(msg`Edit post interaction settings`)}
<Trans>Post interaction settings</Trans> style={[web({maxWidth: 400}), a.w_full]}>
</Text> <Header />
<Text style={[a.text_md, a.pb_xs]}> <PostInteractionSettingsForm {...props} />
<Trans>Customize who can interact with this post.</Trans> <Dialog.Close />
</Text> </Dialog.ScrollableInner>
<Divider />
</View>
) )
} }
@@ -134,12 +125,17 @@ export type PostInteractionSettingsDialogProps = {
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
} }
/**
* Threadgate settings dialog. Used in the thread.
*/
export function PostInteractionSettingsDialog( export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps, props: PostInteractionSettingsDialogProps,
) { ) {
const postThreadContext = usePostThreadContext() const postThreadContext = usePostThreadContext()
return ( return (
<Dialog.Outer control={props.control}> <Dialog.Outer
control={props.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<PostThreadContextProvider context={postThreadContext}> <PostThreadContextProvider context={postThreadContext}>
<PostInteractionSettingsDialogControlledInner {...props} /> <PostInteractionSettingsDialogControlledInner {...props} />
@@ -153,7 +149,7 @@ export function PostInteractionSettingsDialogControlledInner(
) { ) {
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false) const [isSaving, setIsSaving] = useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri}) useThreadgateViewQuery({postUri: props.rootPostUri})
@@ -165,28 +161,28 @@ export function PostInteractionSettingsDialogControlledInner(
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] = const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>() useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] = const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>() useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => { const isThreadgateOwnedByViewer = useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did]) }, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => { const postgateValue = useMemo(() => {
return ( return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri}) editedPostgate || postgate || createPostgateRecord({post: props.postUri})
) )
}, [postgate, editedPostgate, props.postUri]) }, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => { const allowUIValue = useMemo(() => {
return ( return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
) )
}, [threadgateView, editedAllowUISettings]) }, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => { const onSave = useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) { if (!editedPostgate && !editedAllowUISettings) {
props.control.close() props.control.close()
return return
@@ -248,15 +244,24 @@ export function PostInteractionSettingsDialogControlledInner(
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)} label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}> style={[web({maxWidth: 400}), a.w_full]}>
<View style={[a.gap_md]}>
<Header />
{isLoading ? ( {isLoading ? (
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}> <View
style={[
a.flex_1,
a.py_5xl,
a.gap_md,
a.align_center,
a.justify_center,
]}>
<Loader size="xl" /> <Loader size="xl" />
<Text style={[a.italic, a.text_center]}>
<Trans>Loading post interaction settings...</Trans>
</Text>
</View> </View>
) : ( ) : (
<>
<Header />
<PostInteractionSettingsForm <PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer} replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving} isSaving={isSaving}
@@ -266,8 +271,9 @@ export function PostInteractionSettingsDialogControlledInner(
threadgateAllowUISettings={allowUIValue} threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings} onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/> />
</>
)} )}
</View> <Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
@@ -281,11 +287,20 @@ export function PostInteractionSettingsForm({
threadgateAllowUISettings, threadgateAllowUISettings,
onChangeThreadgateAllowUISettings, onChangeThreadgateAllowUISettings,
replySettingsDisabled, replySettingsDisabled,
isDirty,
persist,
onChangePersist,
}: PostInteractionSettingsFormProps) { }: PostInteractionSettingsFormProps) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {data: lists} = useMyListsQuery('curate') const playHaptic = useHaptics()
const [quotesEnabled, setQuotesEnabled] = React.useState( const [showLists, setShowLists] = useState(false)
const {
data: lists,
isPending: isListsPending,
isError: isListsError,
} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = useState(
!( !(
postgate.embeddingRules && postgate.embeddingRules &&
postgate.embeddingRules.find( postgate.embeddingRules.find(
@@ -294,27 +309,7 @@ export function PostInteractionSettingsForm({
), ),
) )
const onPressAudience = (setting: ThreadgateAllowUISetting) => { const onChangeQuotesEnabled = useCallback(
// 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(
(enabled: boolean) => { (enabled: boolean) => {
setQuotesEnabled(enabled) setQuotesEnabled(enabled)
onChangePostgate( onChangePostgate(
@@ -330,36 +325,62 @@ export function PostInteractionSettingsForm({
const noOneCanReply = !!threadgateAllowUISettings.find( const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody', 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 ( return (
<View> <View style={[a.flex_1, a.gap_lg]}>
<View style={[a.flex_1, a.gap_md]}>
<View style={[a.gap_lg]}> <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 && ( {replySettingsDisabled && (
<View <View
style={[ style={[
@@ -373,11 +394,7 @@ export function PostInteractionSettingsForm({
]}> ]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} /> <CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text <Text
style={[ style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}>
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans> <Trans>
Reply settings are chosen by the author of the thread Reply settings are chosen by the author of the thread
</Trans> </Trans>
@@ -385,174 +402,270 @@ export function PostInteractionSettingsForm({
</View> </View>
)} )}
<View <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}>
style={[ <Text style={[a.text_md, a.font_medium]}>
a.gap_sm, <Trans>Who can reply</Trans>
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text> </Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> <Toggle.Group
<Trans>Allow replies from:</Trans> label={_(msg`Set who can reply to your post`)}
</Text> type="radio"
maxSelections={1}
<View style={[a.flex_row, a.gap_sm]}> disabled={replySettingsDisabled}
<Selectable values={
label={_(msg`Everybody`)} everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : []
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
} }
onPress={() => onChange={val => {
if (val.includes('everyone')) {
onChangeThreadgateAllowUISettings([{type: 'everybody'}]) onChangeThreadgateAllowUISettings([{type: 'everybody'}])
} } else if (val.includes('nobody')) {
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}]) onChangeThreadgateAllowUISettings([{type: 'nobody'}])
} else {
onChangeThreadgateAllowUISettings([{type: 'mention'}])
} }
style={{flex: 1}} }}>
disabled={replySettingsDisabled} <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> </View>
</Toggle.Group>
{!noOneCanReply && ( <Toggle.Group
<> label={_(
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> msg`Set precisely which groups of people can reply to your post`,
<Trans>Or combine these options:</Trans> )}
</Text> 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>
<View style={[a.gap_sm]}> <Button
<Selectable label={
label={_(msg`Mentioned users`)} showLists
isSelected={ ? _(msg`Hide lists`)
!!threadgateAllowUISettings.find( : _(msg`Show lists of users to select from`)
v => v.type === 'mention',
)
} }
onPress={() => onPressAudience({type: 'mention'})} accessibilityHint={_(msg`Toggle showing lists`)}
disabled={replySettingsDisabled} accessibilityRole="togglebutton"
/> hitSlop={0}
<Selectable onPress={() => {
label={_(msg`Users you follow`)} playHaptic('Light')
isSelected={ if (isIOS && !showLists) {
!!threadgateAllowUISettings.find( LayoutAnimation.configureNext({
v => v.type === 'following', ...LayoutAnimation.Presets.linear,
) duration: 175,
})
} }
onPress={() => onPressAudience({type: 'following'})} setShowLists(s => !s)
disabled={replySettingsDisabled} }}>
<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)"
/> />
<Selectable </NestedText>
label={_(msg`Your followers`)} </Trans>
isSelected={ )}
!!threadgateAllowUISettings.find( </Toggle.PanelText>
v => v.type === 'followers', <Toggle.PanelIcon
) icon={showLists ? ChevronUpIcon : ChevronDownIcon}
}
onPress={() => onPressAudience({type: 'followers'})}
disabled={replySettingsDisabled}
/> />
{lists && lists.length > 0 </Toggle.Panel>
? lists.map(list => ( </Button>
<Selectable {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} key={list.uri}
label={_(msg`Users in "${list.name}"`)} name={`list:${list.uri}`}
isSelected={ type="checkbox"
!!threadgateAllowUISettings.find( label={_(msg`Allow users in ${list.name} to reply`)}
v => v.type === 'list' && v.list === list.uri, hitSlop={0}>
) {({selected}) => (
} <Toggle.Panel
onPress={() => active={selected}
onPressAudience({type: 'list', list: list.uri}) adjacent={
} i === lists.length - 1 ? 'leading' : 'both'
disabled={replySettingsDisabled} }>
<Toggle.Checkbox />
<UserAvatar
size={24}
type="list"
avatar={list.avatar}
/> />
<Toggle.PanelText>{list.name}</Toggle.PanelText>
</Toggle.Panel>
)}
</Toggle.Item>
)) ))
: // No loading states to avoid jumps for the common case (no lists) ))}
null} </Toggle.PanelGroup>
</Toggle.Group>
</View> </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> </View>
</View> )}
</View>
<Button <Button
disabled={!canSave || isSaving} disabled={!canSave || isSaving}
label={_(msg`Save`)} label={_(msg`Save`)}
onPress={onSave} onPress={onSave}
color="primary" color="primary"
size="large" size="large">
variant="solid" <ButtonText>
style={a.mt_xl}> <Trans>Save</Trans>
<ButtonText>{_(msg`Save`)}</ButtonText> </ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />} {isSaving && <ButtonIcon icon={Loader} />}
</Button> </Button>
</View> </View>
) )
} }
function Selectable({ function Header() {
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return ( return (
<Button <View style={[a.pb_lg]}>
disabled={disabled} <Text style={[a.text_2xl, a.font_bold]}>
onPress={onPress} <Trans>Post interaction settings</Trans>
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> </Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View> </View>
)}
</Button>
) )
} }
@@ -567,7 +680,7 @@ export function usePrefetchPostInteractionSettings({
const agent = useAgent() const agent = useAgent()
const getPost = useGetPost() const getPost = useGetPost()
return React.useCallback(async () => { return useCallback(async () => {
try { try {
await Promise.all([ await Promise.all([
queryClient.prefetchQuery({ queryClient.prefetchQuery({
+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 {createContext, useCallback, useContext, useMemo} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {
import Animated, {LinearTransition} from 'react-native-reanimated' 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 {HITSLOP_10} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import { import {
atoms as a, atoms as a,
native, native,
platform,
type TextStyleProp, type TextStyleProp,
useTheme, useTheme,
type ViewStyleProp, type ViewStyleProp,
@@ -15,6 +23,8 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export * from './Panel'
export type ItemState = { export type ItemState = {
name: string name: string
selected: boolean selected: boolean
@@ -25,7 +35,7 @@ export type ItemState = {
focused: boolean focused: boolean
} }
const ItemContext = React.createContext<ItemState>({ const ItemContext = createContext<ItemState>({
name: '', name: '',
selected: false, selected: false,
disabled: false, disabled: false,
@@ -36,7 +46,7 @@ const ItemContext = React.createContext<ItemState>({
}) })
ItemContext.displayName = 'ToggleItemContext' ItemContext.displayName = 'ToggleItemContext'
const GroupContext = React.createContext<{ const GroupContext = createContext<{
values: string[] values: string[]
disabled: boolean disabled: boolean
type: 'radio' | 'checkbox' type: 'radio' | 'checkbox'
@@ -70,10 +80,11 @@ export type ItemProps = ViewStyleProp & {
onChange?: (selected: boolean) => void onChange?: (selected: boolean) => void
isInvalid?: boolean isInvalid?: boolean
children: ((props: ItemState) => React.ReactNode) | React.ReactNode children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop']
} }
export function useItemContext() { export function useItemContext() {
return React.useContext(ItemContext) return useContext(ItemContext)
} }
export function Group({ export function Group({
@@ -88,9 +99,8 @@ export function Group({
}: GroupProps) { }: GroupProps) {
const groupRole = type === 'radio' ? 'radiogroup' : undefined const groupRole = type === 'radio' ? 'radiogroup' : undefined
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 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 (props: {name: string; value: boolean}) => void
>( >(
({name, value}) => { ({name, value}) => {
@@ -105,25 +115,13 @@ export function Group({
[type, onChange, values], [type, onChange, values],
) )
React.useEffect(() => { const maxReached = !!(
if (type === 'checkbox') { type === 'checkbox' &&
if (
maxSelections && maxSelections &&
values.length >= 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 context = React.useMemo( const context = useMemo(
() => ({ () => ({
values, values,
type, type,
@@ -170,7 +168,7 @@ export function Item({
disabled: groupDisabled, disabled: groupDisabled,
setFieldValue, setFieldValue,
maxSelectionsReached, maxSelectionsReached,
} = React.useContext(GroupContext) } = useContext(GroupContext)
const { const {
state: hovered, state: hovered,
onIn: onHoverIn, onIn: onHoverIn,
@@ -182,19 +180,21 @@ export function Item({
onOut: onPressOut, onOut: onPressOut,
} = useInteractionState() } = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const playHaptic = useHaptics()
const role = groupType === 'radio' ? 'radio' : type const role = groupType === 'radio' ? 'radio' : type
const selected = selectedValues.includes(name) || !!value const selected = selectedValues.includes(name) || !!value
const disabled = const disabled =
groupDisabled || itemDisabled || (!selected && maxSelectionsReached) groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
const onPress = React.useCallback(() => { const onPress = useCallback(() => {
playHaptic('Light')
const next = !selected const next = !selected
setFieldValue({name, value: next}) setFieldValue({name, value: next})
onChange?.(next) onChange?.(next)
}, [name, selected, onChange, setFieldValue]) }, [playHaptic, name, selected, onChange, setFieldValue])
const state = React.useMemo( const state = useMemo(
() => ({ () => ({
name, name,
selected, selected,
@@ -250,8 +250,8 @@ export function LabelText({
style={[ style={[
a.font_semi_bold, a.font_semi_bold,
a.leading_tight, a.leading_tight,
a.user_select_none,
{ {
userSelect: 'none',
color: disabled color: disabled
? t.atoms.text_contrast_low.color ? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_high.color, : t.atoms.text_contrast_high.color,
@@ -287,21 +287,26 @@ export function createSharedToggleStyles({
if (selected) { if (selected) {
base.push({ base.push({
backgroundColor: t.palette.primary_25, backgroundColor: t.palette.primary_500,
borderColor: t.palette.primary_500, borderColor: t.palette.primary_500,
}) })
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: t.palette.primary_100, backgroundColor: t.palette.primary_400,
borderColor: t.palette.primary_600, borderColor: t.palette.primary_400,
}) })
} }
} else { } else {
base.push({
backgroundColor: t.palette.contrast_25,
borderColor: t.palette.contrast_100,
})
if (hovered) { if (hovered) {
baseHover.push({ baseHover.push({
backgroundColor: t.palette.contrast_50, 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, 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) { if (disabled) {
@@ -325,6 +344,13 @@ export function createSharedToggleStyles({
backgroundColor: t.palette.contrast_100, backgroundColor: t.palette.contrast_100,
borderColor: t.palette.contrast_400, borderColor: t.palette.contrast_400,
}) })
if (selected) {
base.push({
backgroundColor: t.palette.primary_100,
borderColor: t.palette.contrast_400,
})
}
} }
return { return {
@@ -350,66 +376,125 @@ export function Checkbox() {
style={[ style={[
a.justify_center, a.justify_center,
a.align_center, a.align_center,
a.rounded_xs,
t.atoms.border_contrast_high, t.atoms.border_contrast_high,
a.transition_color,
{ {
borderWidth: 1, borderWidth: 1,
height: 24, height: 24,
width: 24, width: 24,
borderRadius: 6,
}, },
baseStyles, baseStyles,
hovered ? baseHoverStyles : {}, hovered ? baseHoverStyles : {},
]}> ]}>
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} {selected && <Checkmark width={14} fill={t.palette.white} />}
</View> </View>
) )
} }
export function Switch() { export function Switch() {
const t = useTheme() const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext() const {selected, hovered, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} = const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => {
createSharedToggleStyles({ const base: ViewStyle[] = []
theme: t, const baseHover: ViewStyle[] = []
hovered, const indicator: ViewStyle[] = []
focused,
selected, if (selected) {
disabled, base.push({
isInvalid, 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 ( return (
<View <View
style={[ style={[
a.relative, a.relative,
a.rounded_full, a.rounded_full,
t.atoms.bg, t.atoms.bg,
t.atoms.border_contrast_high,
{ {
borderWidth: 1, height: 28,
height: 24, width: 48,
width: 36,
padding: 3, padding: 3,
}, },
a.transition_color,
baseStyles, baseStyles,
hovered ? baseHoverStyles : {}, hovered ? baseHoverStyles : {},
]}> ]}>
<Animated.View <Animated.View
layout={LinearTransition.duration(100)} layout={LinearTransition.duration(
platform({
web: 100,
default: 200,
}),
).easing(Easing.inOut(Easing.cubic))}
style={[ style={[
a.rounded_full, a.rounded_full,
{ {
height: 16, backgroundColor: t.palette.white,
width: 16, 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, indicatorStyles,
]} ]}
/> />
@@ -420,7 +505,7 @@ export function Switch() {
export function Radio() { export function Radio() {
const t = useTheme() const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = const {selected, hovered, focused, disabled, isInvalid} =
React.useContext(ItemContext) useContext(ItemContext)
const {baseStyles, baseHoverStyles, indicatorStyles} = const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({ createSharedToggleStyles({
theme: t, theme: t,
@@ -437,29 +522,27 @@ export function Radio() {
a.align_center, a.align_center,
a.rounded_full, a.rounded_full,
t.atoms.border_contrast_high, t.atoms.border_contrast_high,
a.transition_color,
{ {
borderWidth: 1, borderWidth: 1,
height: 24, height: 25,
width: 24, width: 25,
margin: -1,
}, },
baseStyles, baseStyles,
hovered ? baseHoverStyles : {}, hovered ? baseHoverStyles : {},
]}> ]}>
{selected ? ( {selected && (
<View <View
style={[ style={[
a.absolute, a.absolute,
a.rounded_full, a.rounded_full,
{height: 16, width: 16}, {height: 12, width: 12},
selected {backgroundColor: t.palette.white},
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles, indicatorStyles,
]} ]}
/> />
) : null} )}
</View> </View>
) )
} }
+7
View File
@@ -19,3 +19,10 @@ export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
export const ChevronTopBottom_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', 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'> } & Omit<SvgProps, 'style' | 'size'>
export const sizes = { export const sizes = {
'2xs': 8,
xs: 12, xs: 12,
sm: 16, sm: 16,
md: 20, md: 20,
@@ -34,7 +34,7 @@ export function VerificationsDialog({
verificationState: FullVerificationState verificationState: FullVerificationState
}) { }) {
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle /> <Dialog.Handle />
<Inner <Inner
control={control} control={control}
+6 -2
View File
@@ -1,10 +1,14 @@
import {type I18n} from '@lingui/core' 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) const d = new Date(date)
return i18n.date(d, { return i18n.date(d, {
dateStyle: 'long', dateStyle,
timeStyle: 'short', timeStyle: 'short',
}) })
} }
@@ -570,7 +570,7 @@ function ExpandedPostDetails({
<BackdatedPostIndicator post={post} /> <BackdatedPostIndicator post={post} />
<View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{niceDate(i18n, post.indexedAt)} {niceDate(i18n, post.indexedAt, 'medium')}
</Text> </Text>
{isRootPost && ( {isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
@@ -194,6 +194,7 @@ export function ItemIcon({
* also so that we can calculate transforms. * also so that we can calculate transforms.
*/ */
const iconSize = { const iconSize = {
'2xs': 8,
xs: 12, xs: 12,
sm: 16, sm: 16,
md: 20, md: 20,
+6 -2
View File
@@ -1,5 +1,5 @@
import {createContext, useContext, useMemo, useRef, useState} from 'react' import {createContext, useContext, useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import { import {
Gesture, Gesture,
GestureDetector, GestureDetector,
@@ -29,8 +29,10 @@ Context.displayName = 'GlobalGestureEventsContext'
export function GlobalGestureEventsProvider({ export function GlobalGestureEventsProvider({
children, children,
style,
}: { }: {
children: React.ReactNode children: React.ReactNode
style?: StyleProp<ViewStyle>
}) { }) {
const refCount = useRef(0) const refCount = useRef(0)
const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), [])
@@ -73,7 +75,9 @@ export function GlobalGestureEventsProvider({
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
<GestureDetector gesture={gesture}> <GestureDetector gesture={gesture}>
<View collapsable={false}>{children}</View> <View collapsable={false} style={style}>
{children}
</View>
</GestureDetector> </GestureDetector>
</Context.Provider> </Context.Provider>
) )
@@ -4,7 +4,13 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {preferencesQueryKey} from '#/state/queries/preferences' import {preferencesQueryKey} from '#/state/queries/preferences'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
export function usePostInteractionSettingsMutation() { export function usePostInteractionSettingsMutation({
onError,
onSettled,
}: {
onError?: (error: Error) => void
onSettled?: () => void
} = {}) {
const qc = useQueryClient() const qc = useQueryClient()
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
@@ -16,5 +22,7 @@ export function usePostInteractionSettingsMutation() {
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
}) })
}, },
onError,
onSettled,
}) })
} }
+9
View File
@@ -0,0 +1,9 @@
import {device, useStorage} from '#/storage'
export function useThreadgateNudged() {
const [threadgateNudged = false, setThreadgateNudged] = useStorage(device, [
'threadgateNudged',
])
return [threadgateNudged, setThreadgateNudged] as const
}
+1
View File
@@ -37,6 +37,7 @@ export type Device = {
devMode: boolean devMode: boolean
demoMode: boolean demoMode: boolean
activitySubscriptionsNudged?: boolean activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
/** /**
* Policy update overlays. New IDs are required for each new announcement. * Policy update overlays. New IDs are required for each new announcement.
+4 -9
View File
@@ -10,11 +10,12 @@ import {
type SelfLabel, type SelfLabel,
} from '#/lib/moderation' } from '#/lib/moderation'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron'
import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -49,7 +50,6 @@ export function LabelsBtn({
return ( return (
<> <>
<Button <Button
variant="solid"
color="secondary" color="secondary"
size="small" size="small"
testID="labelsBtn" testID="labelsBtn"
@@ -60,13 +60,7 @@ export function LabelsBtn({
label={_(msg`Content warnings`)} label={_(msg`Content warnings`)}
accessibilityHint={_( accessibilityHint={_(
msg`Opens a dialog to add a content warning to your post`, msg`Opens a dialog to add a content warning to your post`,
)} )}>
style={[
native({
paddingHorizontal: 8,
paddingVertical: 6,
}),
]}>
<ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} /> <ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} />
<ButtonText numberOfLines={1}> <ButtonText numberOfLines={1}>
{labels.length > 0 ? ( {labels.length > 0 ? (
@@ -75,6 +69,7 @@ export function LabelsBtn({
<Trans>Labels</Trans> <Trans>Labels</Trans>
)} )}
</ButtonText> </ButtonText>
<ButtonIcon icon={TinyChevronIcon} size="2xs" />
</Button> </Button>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
@@ -1,17 +1,31 @@
import {useEffect, useMemo, useState} from 'react'
import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' import {Keyboard, type StyleProp, type ViewStyle} from 'react-native'
import {type AnimatedStyle} from 'react-native-reanimated' import {type AnimatedStyle} from 'react-native-reanimated'
import {type AppBskyFeedPostgate} from '@atproto/api' import {type AppBskyFeedPostgate} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import deepEqual from 'lodash.isequal'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings'
import {native} from '#/alf' import {createPostgateRecord} from '#/state/queries/postgate/util'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {
type ThreadgateAllowUISetting,
threadgateAllowUISettingToAllowRecordValue,
threadgateRecordToAllowUISetting,
} from '#/state/queries/threadgate'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group'
import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged'
export function ThreadgateBtn({ export function ThreadgateBtn({
postgate, postgate,
@@ -29,15 +43,82 @@ export function ThreadgateBtn({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogControl() const control = Dialog.useDialogControl()
const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged()
const [showTooltip, setShowTooltip] = useState(false)
useEffect(() => {
if (!threadgateNudged) {
const timeout = setTimeout(() => {
setShowTooltip(true)
}, 1000)
return () => clearTimeout(timeout)
}
}, [threadgateNudged])
const onDismissTooltip = (visible: boolean) => {
if (visible) return
setThreadgateNudged(true)
setShowTooltip(false)
}
const {data: preferences} = usePreferencesQuery()
const [persist, setPersist] = useState(false)
const onPress = () => { const onPress = () => {
if (isNative && Keyboard.isVisible()) { if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss() Keyboard.dismiss()
} }
setShowTooltip(false)
setThreadgateNudged(true)
control.open() control.open()
} }
const prefThreadgateAllowUISettings = threadgateRecordToAllowUISetting({
$type: 'app.bsky.feed.threadgate',
post: '',
createdAt: new Date().toISOString(),
allow: preferences?.postInteractionSettings.threadgateAllowRules,
})
const prefPostgate = createPostgateRecord({
post: '',
embeddingRules:
preferences?.postInteractionSettings?.postgateEmbeddingRules || [],
})
const isDirty = useMemo(() => {
const everybody = [{type: 'everybody'}]
return (
!deepEqual(
threadgateAllowUISettings,
prefThreadgateAllowUISettings ?? everybody,
) ||
!deepEqual(postgate.embeddingRules, prefPostgate?.embeddingRules ?? [])
)
}, [
prefThreadgateAllowUISettings,
prefPostgate,
threadgateAllowUISettings,
postgate,
])
const {mutate: persistChanges, isPending: isSaving} =
usePostInteractionSettingsMutation({
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to persist threadgate settings', {
safeMessage: err,
})
}
},
onSettled: () => {
control.close(() => {
setPersist(false)
})
},
})
const anyoneCanReply = const anyoneCanReply =
threadgateAllowUISettings.length === 1 && threadgateAllowUISettings.length === 1 &&
threadgateAllowUISettings[0].type === 'everybody' threadgateAllowUISettings[0].type === 'everybody'
@@ -50,34 +131,54 @@ export function ThreadgateBtn({
return ( return (
<> <>
<Tooltip.Outer
visible={showTooltip}
onVisibleChange={onDismissTooltip}
position="top">
<Tooltip.Target>
<Button <Button
variant="solid" color={showTooltip ? 'primary_subtle' : 'secondary'}
color="secondary"
size="small" size="small"
testID="openReplyGateButton" testID="openReplyGateButton"
onPress={onPress} onPress={onPress}
label={label} label={label}
accessibilityHint={_( accessibilityHint={_(
msg`Opens a dialog to choose who can reply to this thread`, msg`Opens a dialog to choose who can interact with this post`,
)} )}>
style={[ <ButtonIcon icon={anyoneCanInteract ? EarthIcon : GroupIcon} />
native({
paddingHorizontal: 8,
paddingVertical: 6,
}),
]}>
<ButtonIcon icon={anyoneCanInteract ? Earth : Group} />
<ButtonText numberOfLines={1}>{label}</ButtonText> <ButtonText numberOfLines={1}>{label}</ButtonText>
<ButtonIcon icon={TinyChevronIcon} size="2xs" />
</Button> </Button>
</Tooltip.Target>
<Tooltip.TextBubble>
<Text>
<Trans>Psst! You can edit who can interact with this post.</Trans>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer>
<PostInteractionSettingsControlledDialog <PostInteractionSettingsControlledDialog
control={control} control={control}
onSave={() => { onSave={() => {
if (persist) {
persistChanges({
threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue(
threadgateAllowUISettings,
),
postgateEmbeddingRules: postgate.embeddingRules ?? [],
})
} else {
control.close() control.close()
}
}} }}
isSaving={isSaving}
postgate={postgate} postgate={postgate}
onChangePostgate={onChangePostgate} onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings} threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
isDirty={isDirty}
persist={persist}
onChangePersist={setPersist}
/> />
</> </>
) )
+9
View File
@@ -155,6 +155,15 @@ export function Forms() {
</View> </View>
</Toggle.Group> </Toggle.Group>
<Toggle.Item name="d" disabled value label="Click me">
<Toggle.Switch />
<Toggle.LabelText>Click me</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item name="d" disabled value isInvalid label="Click me">
<Toggle.Switch />
<Toggle.LabelText>Click me</Toggle.LabelText>
</Toggle.Item>
<Toggle.Group <Toggle.Group
label="Toggle" label="Toggle"
type="checkbox" type="checkbox"
+4 -1
View File
@@ -3,8 +3,9 @@ import {Modal, View} from 'react-native'
import {useDialogStateControlContext} from '#/state/dialogs' import {useDialogStateControlContext} from '#/state/dialogs'
import {useComposerState} from '#/state/shell/composer' import {useComposerState} from '#/state/shell/composer'
import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip'
export function Composer({}: {winHeight: number}) { export function Composer({}: {winHeight: number}) {
const {setFullyExpandedCount} = useDialogStateControlContext() const {setFullyExpandedCount} = useDialogStateControlContext()
@@ -33,6 +34,7 @@ export function Composer({}: {winHeight: number}) {
animationType="slide" animationType="slide"
onRequestClose={() => ref.current?.onPressCancel()}> onRequestClose={() => ref.current?.onPressCancel()}>
<View style={[t.atoms.bg, a.flex_1]}> <View style={[t.atoms.bg, a.flex_1]}>
<TooltipSheetCompatProvider>
<ComposePost <ComposePost
cancelRef={ref} cancelRef={ref}
replyTo={state?.replyTo} replyTo={state?.replyTo}
@@ -44,6 +46,7 @@ export function Composer({}: {winHeight: number}) {
imageUris={state?.imageUris} imageUris={state?.imageUris}
videoUri={state?.videoUri} videoUri={state?.videoUri}
/> />
</TooltipSheetCompatProvider>
</View> </View>
</Modal> </Modal>
) )