diff --git a/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..c8d9d51d0e
--- /dev/null
+++ b/assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
index 6db6e35fac..fa42e37d5a 100644
--- a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
+++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
@@ -243,13 +243,18 @@ class BottomSheetView(
val bottomSheet = dialog.findViewById(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
}
}
}
diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts
index 4009f2ab28..a52b4201ac 100644
--- a/modules/bottom-sheet/index.ts
+++ b/modules/bottom-sheet/index.ts
@@ -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 {
diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
index d367ac300c..aa69cfd599 100644
--- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
+++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
@@ -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()
+ }
}}
/>
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 2fdcd64914..efac8468d0 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -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,
diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx
index f53749ef0d..995f9d412e 100644
--- a/src/components/Select/index.web.tsx
+++ b/src/components/Select/index.web.tsx
@@ -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({items, renderItem}: ContentProps) {
+export function Content({
+ items,
+ renderItem,
+ valueExtractor = defaultItemValueExtractor,
+}: ContentProps) {
const t = useTheme()
const selectedValue = useContext(SelectedValueContext)
@@ -198,7 +200,11 @@ export function Content({items, renderItem}: ContentProps) {
- {items.map((item, index) => renderItem(item, index, selectedValue))}
+ {items.map((item, index) => (
+
+ {renderItem(item, index, selectedValue)}
+
+ ))}
@@ -209,6 +215,10 @@ export function Content({items, renderItem}: ContentProps) {
)
}
+function defaultItemValueExtractor(item: any) {
+ return item.value
+}
+
const ItemContext = createContext<{
hovered: boolean
focused: boolean
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
index a7d1510205..e916ee0ed3 100644
--- a/src/components/Tooltip/index.tsx
+++ b/src/components/Tooltip/index.tsx
@@ -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 | 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(null)
+ return (
+
+
+
+
+ {children}
+
+
+
+
+
+ )
+}
+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(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 (
-
+ setHasLayedOut(true)}>
{children}
)
@@ -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 (
{children}>
+}
+Provider.displayName = 'TooltipProvider'
+
type TooltipContextType = {
position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void
}
-const TooltipContext = createContext({
+const TooltipContext = createContext>({
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 (
{children}
@@ -60,7 +62,7 @@ export function Content({
label: string
}) {
const t = useTheme()
- const {position, onVisibleChange} = useContext(TooltipContext)
+ const {position} = useContext(TooltipContext)
return (
onVisibleChange(false)}
+ onInteractOutside={evt => {
+ if (evt.type === 'dismissableLayer.focusOutside') {
+ evt.preventDefault()
+ }
+ }}
style={flatten([
a.rounded_sm,
select(t.name, {
diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
index a10508f2e4..ae1c68ab82 100644
--- a/src/components/WhoCanReply.tsx
+++ b/src/components/WhoCanReply.tsx
@@ -1,4 +1,4 @@
-import {Fragment, useMemo} from 'react'
+import {Fragment, useMemo, useRef} from 'react'
import {
Keyboard,
Platform,
@@ -22,7 +22,7 @@ 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 +30,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 +69,11 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
postUri: post.uri,
rootPostUri: rootUri,
})
+ const prefetchPromise = useRef>(Promise.resolve())
+
+ const prefetch = () => {
+ prefetchPromise.current = prefetchPostInteractionSettings()
+ }
const anyoneCanReply =
settings.length === 1 && settings[0].type === 'everybody'
@@ -84,7 +89,14 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
Keyboard.dismiss()
}
if (isThreadAuthor) {
- editDialogControl.open()
+ // 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 {
infoDialogControl.open()
}
@@ -100,18 +112,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}) => (
-
+ {({hovered, focused, pressed}) => (
+
@@ -119,14 +140,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}
{isThreadAuthor && (
-
+
)}
)}
@@ -164,7 +187,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
}
diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx
index 71253dca9b..84d8c80518 100644
--- a/src/components/activity-notifications/SubscribeProfileButton.tsx
+++ b/src/components/activity-notifications/SubscribeProfileButton.tsx
@@ -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 (
<>
@@ -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}>
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
index 5b9fc262dc..b499b40751 100644
--- a/src/components/dialogs/PostInteractionSettingsDialog.tsx
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -1,16 +1,17 @@
-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 {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 +38,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 +57,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 +70,39 @@ 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()
-
return (
-
+
-
-
-
-
-
-
- You can set default interaction settings in{' '}
-
- Settings → Moderation → Interaction settings
-
- .
-
-
-
-
-
+
)
}
-export function Header() {
+function DialogInner(props: Omit) {
+ const {_} = useLingui()
+
return (
-
-
- Post interaction settings
-
-
- Customize who can interact with this post.
-
-
-
+
+
+
+
+
)
}
@@ -134,12 +125,17 @@ export type PostInteractionSettingsDialogProps = {
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
+/**
+ * Threadgate settings dialog. Used in the thread.
+ */
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
const postThreadContext = usePostThreadContext()
return (
-
+
@@ -153,7 +149,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 +161,28 @@ export function PostInteractionSettingsDialogControlledInner(
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
- React.useState()
+ useState()
const [editedAllowUISettings, setEditedAllowUISettings] =
- React.useState()
+ useState()
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 +244,24 @@ export function PostInteractionSettingsDialogControlledInner(
return (
-
-
-
- {isLoading ? (
-
-
-
- ) : (
+ style={[web({maxWidth: 400}), a.w_full]}>
+ {isLoading ? (
+
+
+
+ Loading post interaction settings...
+
+
+ ) : (
+ <>
+
- )}
-
+ >
+ )}
+
)
}
@@ -281,11 +287,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 +309,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 +325,347 @@ 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 (
-
-
-
-
-
- Quote settings
-
-
-
-
- Allow quote posts
-
-
-
-
-
-
-
- {replySettingsDisabled && (
-
-
-
-
- Reply settings are chosen by the author of the thread
-
-
-
- )}
-
+
+
+ {replySettingsDisabled && (
-
- Reply settings
+
+
+
+ Reply settings are chosen by the author of the thread
+
-
-
- Allow replies from:
-
-
-
- v.type === 'everybody')
- }
- onPress={() =>
- onChangeThreadgateAllowUISettings([{type: 'everybody'}])
- }
- style={{flex: 1}}
- disabled={replySettingsDisabled}
- />
-
- onChangeThreadgateAllowUISettings([{type: 'nobody'}])
- }
- style={{flex: 1}}
- disabled={replySettingsDisabled}
- />
-
-
- {!noOneCanReply && (
- <>
-
- Or combine these options:
-
-
-
- v.type === 'mention',
- )
- }
- onPress={() => onPressAudience({type: 'mention'})}
- disabled={replySettingsDisabled}
- />
- v.type === 'following',
- )
- }
- onPress={() => onPressAudience({type: 'following'})}
- disabled={replySettingsDisabled}
- />
- v.type === 'followers',
- )
- }
- onPress={() => onPressAudience({type: 'followers'})}
- disabled={replySettingsDisabled}
- />
- {lists && lists.length > 0
- ? lists.map(list => (
- 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}
-
- >
- )}
+ )}
+
+
+
+ Who can reply
+
+
+ {
+ if (val.includes('everyone')) {
+ onChangeThreadgateAllowUISettings([{type: 'everybody'}])
+ } else if (val.includes('nobody')) {
+ onChangeThreadgateAllowUISettings([{type: 'nobody'}])
+ } else {
+ onChangeThreadgateAllowUISettings([{type: 'mention'}])
+ }
+ }}>
+
+
+ {({selected}) => (
+
+
+
+ Anyone
+
+
+ )}
+
+
+ {({selected}) => (
+
+
+
+ Nobody
+
+
+ )}
+
+
+
+
+
+
+
+ {({selected}) => (
+
+
+
+ Your followers
+
+
+ )}
+
+
+ {({selected}) => (
+
+
+
+ People you follow
+
+
+ )}
+
+
+ {({selected}) => (
+
+
+
+ People you mention
+
+
+ )}
+
+
+
+ {showLists &&
+ (isListsPending ? (
+
+
+ Loading lists...
+
+
+ ) : isListsError ? (
+
+
+
+ An error occurred while loading your lists :/
+
+
+
+ ) : lists.length === 0 ? (
+
+
+ You don't have any lists yet.
+
+
+ ) : (
+ lists.map((list, i) => (
+
+ {({selected}) => (
+
+
+
+ {list.name}
+
+ )}
+
+ ))
+ ))}
+
+
+
+ {({selected}) => (
+
+
+ Allow quote posts
+
+
+
+ )}
+
+
+ {typeof persist !== 'undefined' && (
+
+ {isDirty ? (
+ onChangePersist?.(!persist)}>
+
+
+ Save these options for next time
+
+
+ ) : (
+
+ These are your default settings
+
+ )}
+
+ )}
+
)
}
-function Selectable({
- label,
- isSelected,
- onPress,
- style,
- disabled,
-}: {
- label: string
- isSelected: boolean
- onPress: () => void
- style?: StyleProp
- disabled?: boolean
-}) {
- const t = useTheme()
+function Header() {
return (
-
+
+
+ Post interaction settings
+
+
)
}
@@ -567,7 +680,7 @@ export function usePrefetchPostInteractionSettings({
const agent = useAgent()
const getPost = useGetPost()
- return React.useCallback(async () => {
+ return useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
diff --git a/src/components/forms/Toggle/Panel.tsx b/src/components/forms/Toggle/Panel.tsx
new file mode 100644
index 0000000000..d874750db6
--- /dev/null
+++ b/src/components/forms/Toggle/Panel.tsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function PanelText({
+ children,
+ icon,
+}: {
+ children: React.ReactNode
+ icon?: React.ComponentType
+}) {
+ const t = useTheme()
+ const ctx = useContext(PanelContext)
+
+ const text = (
+
+ {children}
+
+ )
+
+ if (icon) {
+ // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
+ return (
+
+
+ {text}
+
+ )
+ }
+
+ return text
+}
+
+export function PanelIcon({
+ icon: Icon,
+}: {
+ icon: React.ComponentType
+}) {
+ const t = useTheme()
+ const ctx = useContext(PanelContext)
+ return (
+
+ )
+}
+
+/**
+ * A group of panels. TODO: auto-leading/trailing
+ */
+export function PanelGroup({children}: {children: React.ReactNode}) {
+ return {children}
+}
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle/index.tsx
similarity index 68%
rename from src/components/forms/Toggle.tsx
rename to src/components/forms/Toggle/index.tsx
index 849e014fac..60fa50478a 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle/index.tsx
@@ -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({
+const ItemContext = createContext({
name: '',
selected: false,
disabled: false,
@@ -36,7 +46,7 @@ const ItemContext = React.createContext({
})
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 ? : null}
+ {selected && }
)
}
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 (
@@ -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 && (
- ) : null}
+ )}
)
}
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
index 4d252ee3ca..b033e3c66b 100644
--- a/src/components/icons/Chevron.tsx
+++ b/src/components/icons/Chevron.tsx
@@ -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',
+})
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index bc1e045a48..0f208240f3 100644
--- a/src/components/icons/common.tsx
+++ b/src/components/icons/common.tsx
@@ -13,6 +13,7 @@ export type Props = {
} & Omit
export const sizes = {
+ '2xs': 8,
xs: 12,
sm: 16,
md: 20,
diff --git a/src/components/verification/VerificationsDialog.tsx b/src/components/verification/VerificationsDialog.tsx
index 7e6b66c816..4091b80531 100644
--- a/src/components/verification/VerificationsDialog.tsx
+++ b/src/components/verification/VerificationsDialog.tsx
@@ -34,7 +34,7 @@ export function VerificationsDialog({
verificationState: FullVerificationState
}) {
return (
-
+
- {niceDate(i18n, post.indexedAt)}
+ {niceDate(i18n, post.indexedAt, 'medium')}
{isRootPost && (
diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx
index 14e341de21..5010d42fca 100644
--- a/src/screens/Settings/components/SettingsList.tsx
+++ b/src/screens/Settings/components/SettingsList.tsx
@@ -194,6 +194,7 @@ export function ItemIcon({
* also so that we can calculate transforms.
*/
const iconSize = {
+ '2xs': 8,
xs: 12,
sm: 16,
md: 20,
diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx
index 8941d9ef46..2f0d652210 100644
--- a/src/state/global-gesture-events/index.tsx
+++ b/src/state/global-gesture-events/index.tsx
@@ -1,5 +1,5 @@
import {createContext, useContext, useMemo, useRef, useState} from 'react'
-import {View} from 'react-native'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
import {
Gesture,
GestureDetector,
@@ -29,8 +29,10 @@ Context.displayName = 'GlobalGestureEventsContext'
export function GlobalGestureEventsProvider({
children,
+ style,
}: {
children: React.ReactNode
+ style?: StyleProp
}) {
const refCount = useRef(0)
const events = useMemo(() => new EventEmitter(), [])
@@ -73,7 +75,9 @@ export function GlobalGestureEventsProvider({
return (
- {children}
+
+ {children}
+
)
diff --git a/src/state/queries/post-interaction-settings.ts b/src/state/queries/post-interaction-settings.ts
index 6f2b7d9088..af178d7f8b 100644
--- a/src/state/queries/post-interaction-settings.ts
+++ b/src/state/queries/post-interaction-settings.ts
@@ -4,7 +4,13 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {preferencesQueryKey} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
-export function usePostInteractionSettingsMutation() {
+export function usePostInteractionSettingsMutation({
+ onError,
+ onSettled,
+}: {
+ onError?: (error: Error) => void
+ onSettled?: () => void
+} = {}) {
const qc = useQueryClient()
const agent = useAgent()
return useMutation({
@@ -16,5 +22,7 @@ export function usePostInteractionSettingsMutation() {
queryKey: preferencesQueryKey,
})
},
+ onError,
+ onSettled,
})
}
diff --git a/src/storage/hooks/threadgate-nudged.ts b/src/storage/hooks/threadgate-nudged.ts
new file mode 100644
index 0000000000..b1786d35dd
--- /dev/null
+++ b/src/storage/hooks/threadgate-nudged.ts
@@ -0,0 +1,9 @@
+import {device, useStorage} from '#/storage'
+
+export function useThreadgateNudged() {
+ const [threadgateNudged = false, setThreadgateNudged] = useStorage(device, [
+ 'threadgateNudged',
+ ])
+
+ return [threadgateNudged, setThreadgateNudged] as const
+}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index d562d9fae4..02923436a5 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -37,6 +37,7 @@ export type Device = {
devMode: boolean
demoMode: boolean
activitySubscriptionsNudged?: boolean
+ threadgateNudged?: boolean
/**
* Policy update overlays. New IDs are required for each new announcement.
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 592d954a44..95a93490cd 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -10,11 +10,12 @@ import {
type SelfLabel,
} from '#/lib/moderation'
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 * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
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 {Text} from '#/components/Typography'
@@ -49,7 +50,6 @@ export function LabelsBtn({
return (
<>
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 4f46351b27..788e831dc0 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -1,17 +1,31 @@
+import {useEffect, useMemo, useState} from 'react'
import {Keyboard, type StyleProp, type ViewStyle} from 'react-native'
import {type AnimatedStyle} from 'react-native-reanimated'
import {type AppBskyFeedPostgate} from '@atproto/api'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
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 {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
-import {native} from '#/alf'
+import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings'
+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 * as Dialog from '#/components/Dialog'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
-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 TinyChevronIcon} from '#/components/icons/Chevron'
+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({
postgate,
@@ -29,15 +43,82 @@ export function ThreadgateBtn({
}) {
const {_} = useLingui()
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 = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
+ setShowTooltip(false)
+ setThreadgateNudged(true)
+
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 =
threadgateAllowUISettings.length === 1 &&
threadgateAllowUISettings[0].type === 'everybody'
@@ -50,34 +131,54 @@ export function ThreadgateBtn({
return (
<>
-
+
+
+
+
+
+
+ Psst! You can edit who can interact with this post.
+
+
+
+
{
- control.close()
+ if (persist) {
+ persistChanges({
+ threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue(
+ threadgateAllowUISettings,
+ ),
+ postgateEmbeddingRules: postgate.embeddingRules ?? [],
+ })
+ } else {
+ control.close()
+ }
}}
+ isSaving={isSaving}
postgate={postgate}
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
+ isDirty={isDirty}
+ persist={persist}
+ onChangePersist={setPersist}
/>
>
)
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index 45a1d9aa00..3cf6e47232 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -155,6 +155,15 @@ export function Forms() {
+
+
+ Click me
+
+
+
+ Click me
+
+
ref.current?.onPressCancel()}>
-
+
+
+
)