From b422765aed649f3381bea305c3c8e29bf50e089c Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 14 Nov 2025 16:21:37 +0200 Subject: [PATCH] [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> --- ...yChevronBottom_stroke2_corner0_rounded.svg | 1 + .../modules/bottomsheet/BottomSheetView.kt | 7 +- modules/bottom-sheet/index.ts | 4 +- .../src/BottomSheetNativeComponent.tsx | 18 +- src/components/Button.tsx | 3 +- src/components/Select/index.web.tsx | 26 +- src/components/Tooltip/index.tsx | 74 +- src/components/Tooltip/index.web.tsx | 22 +- src/components/WhoCanReply.tsx | 59 +- .../SubscribeProfileButton.tsx | 20 +- .../dialogs/PostInteractionSettingsDialog.tsx | 691 ++++++++++-------- src/components/forms/Toggle/Panel.tsx | 120 +++ .../forms/{Toggle.tsx => Toggle/index.tsx} | 231 ++++-- src/components/icons/Chevron.tsx | 7 + src/components/icons/common.tsx | 1 + .../verification/VerificationsDialog.tsx | 2 +- src/lib/strings/time.ts | 8 +- .../components/ThreadItemAnchor.tsx | 2 +- .../Settings/components/SettingsList.tsx | 1 + src/state/global-gesture-events/index.tsx | 8 +- .../queries/post-interaction-settings.ts | 10 +- src/storage/hooks/threadgate-nudged.ts | 9 + src/storage/schema.ts | 1 + src/view/com/composer/labels/LabelsBtn.tsx | 13 +- .../com/composer/threadgate/ThreadgateBtn.tsx | 151 +++- src/view/screens/Storybook/Forms.tsx | 9 + src/view/shell/Composer.ios.tsx | 27 +- 27 files changed, 1057 insertions(+), 468 deletions(-) create mode 100644 assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg create mode 100644 src/components/forms/Toggle/Panel.tsx rename src/components/forms/{Toggle.tsx => Toggle/index.tsx} (68%) create mode 100644 src/storage/hooks/threadgate-nudged.ts 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()}> - + + + )