Bluesky app fork with some witchin' additions 💫

[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>

authored by samuel.fm

surfdude29 and committed by
GitHub
b422765a b56ee74c

+1048 -459
+1
assets/icons/tinyChevronBottom_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M10.928 18.882a1.95 1.95 0 0 0 2.452-.25l9-9a1.953 1.953 0 0 0-2.76-2.76L12 14.493l-7.62-7.62a1.952 1.952 0 0 0-2.76 2.76l9 9 .308.25Z"/></svg>
+6 -1
modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt
··· 243 243 val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet) 244 244 bottomSheet?.let { 245 245 val behavior = BottomSheetBehavior.from(it) 246 + val currentState = behavior.state 246 247 247 - behavior.halfExpandedRatio = getHalfExpandedRatio(contentHeight) 248 + val oldRatio = behavior.halfExpandedRatio 249 + var newRatio = getHalfExpandedRatio(contentHeight) 250 + behavior.halfExpandedRatio = newRatio 248 251 249 252 if (contentHeight > this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) { 250 253 behavior.state = BottomSheetBehavior.STATE_EXPANDED 251 254 } else if (contentHeight < this.safeScreenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) { 255 + behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 256 + } else if (currentState == BottomSheetBehavior.STATE_HALF_EXPANDED && oldRatio != newRatio) { 252 257 behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED 253 258 } 254 259 }
+2 -2
modules/bottom-sheet/index.ts
··· 1 1 import {BottomSheet} from './src/BottomSheet' 2 2 import { 3 3 BottomSheetSnapPoint, 4 - BottomSheetState, 5 - BottomSheetViewProps, 4 + type BottomSheetState, 5 + type BottomSheetViewProps, 6 6 } from './src/BottomSheet.types' 7 7 import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent' 8 8 import {
+15 -3
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 112 112 onStateChange={this.onStateChange} 113 113 extraStyles={extraStyles} 114 114 onLayout={e => { 115 - const {height} = e.nativeEvent.layout 116 - this.setState({viewHeight: height}) 117 - this.updateLayout() 115 + if (isIOS15) { 116 + const {height} = e.nativeEvent.layout 117 + this.setState({viewHeight: height}) 118 + } 119 + if (Platform.OS === 'android') { 120 + // TEMP HACKFIX: I had to timebox this, but this is Bad. 121 + // On Android, if you run updateLayout() immediately, 122 + // it will take ages to actually run on the native side. 123 + // However, adding literally any delay will fix this, including 124 + // a console.log() - just sending the log to the CLI is enough. 125 + // TODO: Get to the bottom of this and fix it properly! -sfn 126 + setTimeout(() => this.updateLayout()) 127 + } else { 128 + this.updateLayout() 129 + } 118 130 }} 119 131 /> 120 132 </Portal>
+2 -1
src/components/Button.tsx
··· 798 798 * also so that we can calculate transforms. 799 799 */ 800 800 const iconSize = { 801 + '2xs': 8, 801 802 xs: 12, 802 803 sm: 16, 803 804 md: 18, ··· 842 843 style={[ 843 844 a.z_20, 844 845 { 845 - width: iconContainerSize, 846 + width: size === '2xs' ? 10 : iconContainerSize, 846 847 height: iconContainerSize, 847 848 marginLeft: iconNegativeMargin, 848 849 marginRight: iconNegativeMargin,
+18 -8
src/components/Select/index.web.tsx
··· 1 - import {createContext, forwardRef, useContext, useMemo} from 'react' 1 + import {createContext, forwardRef, Fragment, useContext, useMemo} from 'react' 2 2 import {View} from 'react-native' 3 3 import {Select as RadixSelect} from 'radix-ui' 4 4 ··· 96 96 style={flatten([ 97 97 a.flex, 98 98 a.relative, 99 - t.atoms.bg_contrast_25, 100 - a.rounded_sm, 99 + t.atoms.bg_contrast_50, 101 100 a.w_full, 102 101 a.align_center, 103 102 a.gap_sm, ··· 106 105 a.px_md, 107 106 a.pointer, 108 107 { 108 + borderRadius: 10, 109 109 maxWidth: 400, 110 110 outline: 0, 111 111 borderWidth: 2, 112 112 borderStyle: 'solid', 113 113 borderColor: focused 114 114 ? t.palette.primary_500 115 - : hovered 116 - ? t.palette.contrast_100 117 - : t.palette.contrast_25, 115 + : t.palette.contrast_50, 118 116 }, 119 117 ])}> 120 118 {children} ··· 140 138 ) 141 139 } 142 140 143 - export function Content<T>({items, renderItem}: ContentProps<T>) { 141 + export function Content<T>({ 142 + items, 143 + renderItem, 144 + valueExtractor = defaultItemValueExtractor, 145 + }: ContentProps<T>) { 144 146 const t = useTheme() 145 147 const selectedValue = useContext(SelectedValueContext) 146 148 ··· 198 200 <ChevronUpIcon style={[t.atoms.text]} size="xs" /> 199 201 </RadixSelect.ScrollUpButton> 200 202 <RadixSelect.Viewport style={flatten([a.p_xs])}> 201 - {items.map((item, index) => renderItem(item, index, selectedValue))} 203 + {items.map((item, index) => ( 204 + <Fragment key={valueExtractor(item)}> 205 + {renderItem(item, index, selectedValue)} 206 + </Fragment> 207 + ))} 202 208 </RadixSelect.Viewport> 203 209 <RadixSelect.ScrollDownButton style={flatten(down)}> 204 210 <ChevronDownIcon style={[t.atoms.text]} size="xs" /> ··· 207 213 </RadixSelect.Content> 208 214 </RadixSelect.Portal> 209 215 ) 216 + } 217 + 218 + function defaultItemValueExtractor(item: any) { 219 + return item.value 210 220 } 211 221 212 222 const ItemContext = createContext<{
+65 -9
src/components/Tooltip/index.tsx
··· 12 12 import Animated, {Easing, ZoomIn} from 'react-native-reanimated' 13 13 import {useSafeAreaInsets} from 'react-native-safe-area-context' 14 14 15 + import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' 16 + import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' 15 17 import {atoms as a, select, useTheme} from '#/alf' 16 18 import {useOnGesture} from '#/components/hooks/useOnGesture' 17 - import {Portal} from '#/components/Portal' 19 + import {createPortalGroup, Portal as RootPortal} from '#/components/Portal' 18 20 import { 19 21 ARROW_HALF_SIZE, 20 22 ARROW_SIZE, ··· 22 24 MIN_EDGE_SPACE, 23 25 } from '#/components/Tooltip/const' 24 26 import {Text} from '#/components/Typography' 27 + 28 + const TooltipPortal = createPortalGroup() 29 + const TooltipProviderContext = 30 + createContext<React.RefObject<View | null> | null>(null) 31 + 32 + /** 33 + * Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container, 34 + * such as in the composer sheet. 35 + * 36 + * Only really necessary on iOS but can work on Android. 37 + */ 38 + export function SheetCompatProvider({children}: {children: React.ReactNode}) { 39 + const ref = useRef<View | null>(null) 40 + return ( 41 + <GlobalGestureEventsProvider style={[a.flex_1]}> 42 + <TooltipPortal.Provider> 43 + <View ref={ref} collapsable={false} style={[a.flex_1]}> 44 + <TooltipProviderContext value={ref}> 45 + {children} 46 + </TooltipProviderContext> 47 + </View> 48 + <TooltipPortal.Outlet /> 49 + </TooltipPortal.Provider> 50 + </GlobalGestureEventsProvider> 51 + ) 52 + } 53 + SheetCompatProvider.displayName = 'TooltipSheetCompatProvider' 25 54 26 55 /** 27 56 * These are native specific values, not shared with web ··· 120 149 121 150 export function Target({children}: {children: React.ReactNode}) { 122 151 const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) 152 + const [hasLayedOut, setHasLayedOut] = useState(false) 123 153 const targetRef = useRef<View>(null) 154 + const containerRef = useContext(TooltipProviderContext) 155 + const keyboardIsOpen = useIsKeyboardVisible() 124 156 125 157 useEffect(() => { 126 - if (!shouldMeasure) return 158 + if (!shouldMeasure || !hasLayedOut) return 127 159 /* 128 160 * Once opened, measure the dimensions and position of the target 129 161 */ 130 - targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { 131 - if (pageX !== undefined && pageY !== undefined && width && height) { 132 - setTargetMeasurements({x: pageX, y: pageY, width, height}) 133 - } 134 - }) 135 - }, [shouldMeasure, setTargetMeasurements]) 162 + 163 + if (containerRef?.current) { 164 + targetRef.current?.measureLayout( 165 + containerRef.current, 166 + (x, y, width, height) => { 167 + if (x !== undefined && y !== undefined && width && height) { 168 + setTargetMeasurements({x, y, width, height}) 169 + } 170 + }, 171 + ) 172 + } else { 173 + targetRef.current?.measure((_x, _y, width, height, x, y) => { 174 + if (x !== undefined && y !== undefined && width && height) { 175 + setTargetMeasurements({x, y, width, height}) 176 + } 177 + }) 178 + } 179 + }, [ 180 + shouldMeasure, 181 + setTargetMeasurements, 182 + hasLayedOut, 183 + containerRef, 184 + keyboardIsOpen, 185 + ]) 136 186 137 187 return ( 138 - <View collapsable={false} ref={targetRef}> 188 + <View 189 + collapsable={false} 190 + ref={targetRef} 191 + onLayout={() => setHasLayedOut(true)}> 139 192 {children} 140 193 </View> 141 194 ) ··· 150 203 }) { 151 204 const {position, visible, onVisibleChange} = useContext(TooltipContext) 152 205 const {targetMeasurements} = useContext(TargetContext) 206 + const isWithinProvider = !!useContext(TooltipProviderContext) 153 207 const requestClose = useCallback(() => { 154 208 onVisibleChange(false) 155 209 }, [onVisibleChange]) 156 210 157 211 if (!visible || !targetMeasurements) return null 212 + 213 + const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal 158 214 159 215 return ( 160 216 <Portal>
+14 -8
src/components/Tooltip/index.web.tsx
··· 11 11 } from '#/components/Tooltip/const' 12 12 import {Text} from '#/components/Typography' 13 13 14 + // Portal Provider on native, but we actually don't need to do anything here 15 + export function Provider({children}: {children: React.ReactNode}) { 16 + return <>{children}</> 17 + } 18 + Provider.displayName = 'TooltipProvider' 19 + 14 20 type TooltipContextType = { 15 21 position: 'top' | 'bottom' 16 22 onVisibleChange: (open: boolean) => void 17 23 } 18 24 19 - const TooltipContext = createContext<TooltipContextType>({ 25 + const TooltipContext = createContext<Pick<TooltipContextType, 'position'>>({ 20 26 position: 'bottom', 21 - onVisibleChange: () => {}, 22 27 }) 23 28 TooltipContext.displayName = 'TooltipContext' 24 29 ··· 33 38 visible: boolean 34 39 onVisibleChange: (visible: boolean) => void 35 40 }) { 36 - const ctx = useMemo( 37 - () => ({position, onVisibleChange}), 38 - [position, onVisibleChange], 39 - ) 41 + const ctx = useMemo(() => ({position}), [position]) 40 42 return ( 41 43 <Popover.Root open={visible} onOpenChange={onVisibleChange}> 42 44 <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> ··· 60 62 label: string 61 63 }) { 62 64 const t = useTheme() 63 - const {position, onVisibleChange} = useContext(TooltipContext) 65 + const {position} = useContext(TooltipContext) 64 66 return ( 65 67 <Popover.Portal> 66 68 <Popover.Content ··· 69 71 side={position} 70 72 sideOffset={4} 71 73 collisionPadding={MIN_EDGE_SPACE} 72 - onInteractOutside={() => onVisibleChange(false)} 74 + onInteractOutside={evt => { 75 + if (evt.type === 'dismissableLayer.focusOutside') { 76 + evt.preventDefault() 77 + } 78 + }} 73 79 style={flatten([ 74 80 a.rounded_sm, 75 81 select(t.name, {
+43 -16
src/components/WhoCanReply.tsx
··· 1 - import {Fragment, useMemo} from 'react' 1 + import {Fragment, useMemo, useRef} from 'react' 2 2 import { 3 3 Keyboard, 4 4 Platform, ··· 22 22 type ThreadgateAllowUISetting, 23 23 threadgateViewToAllowUISetting, 24 24 } from '#/state/queries/threadgate' 25 - import {atoms as a, useTheme, web} from '#/alf' 25 + import {atoms as a, native, useTheme, web} from '#/alf' 26 26 import {Button, ButtonText} from '#/components/Button' 27 27 import * as Dialog from '#/components/Dialog' 28 28 import {useDialogControl} from '#/components/Dialog' ··· 30 30 PostInteractionSettingsDialog, 31 31 usePrefetchPostInteractionSettings, 32 32 } from '#/components/dialogs/PostInteractionSettingsDialog' 33 - import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 34 - import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 35 - import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 33 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron' 34 + import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign' 35 + import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 36 + import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 36 37 import {InlineLinkText} from '#/components/Link' 37 38 import {Text} from '#/components/Typography' 38 39 import * as bsky from '#/types/bsky' 39 - import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' 40 40 41 41 interface WhoCanReplyProps { 42 42 post: AppBskyFeedDefs.PostView ··· 69 69 postUri: post.uri, 70 70 rootPostUri: rootUri, 71 71 }) 72 + const prefetchPromise = useRef<Promise<void>>(Promise.resolve()) 73 + 74 + const prefetch = () => { 75 + prefetchPromise.current = prefetchPostInteractionSettings() 76 + } 72 77 73 78 const anyoneCanReply = 74 79 settings.length === 1 && settings[0].type === 'everybody' ··· 84 89 Keyboard.dismiss() 85 90 } 86 91 if (isThreadAuthor) { 87 - editDialogControl.open() 92 + // wait on prefetch if it manages to resolve in under 200ms 93 + // otherwise, proceed immediately and show the spinner -sfn 94 + Promise.race([ 95 + prefetchPromise.current, 96 + new Promise(res => setTimeout(res, 200)), 97 + ]).finally(() => { 98 + editDialogControl.open() 99 + }) 88 100 } else { 89 101 infoDialogControl.open() 90 102 } ··· 100 112 {...(isThreadAuthor 101 113 ? Platform.select({ 102 114 web: { 103 - onHoverIn: prefetchPostInteractionSettings, 115 + onHoverIn: prefetch, 104 116 }, 105 117 native: { 106 - onPressIn: prefetchPostInteractionSettings, 118 + onPressIn: prefetch, 107 119 }, 108 120 }) 109 121 : {})} 110 122 hitSlop={HITSLOP_10}> 111 - {({hovered}) => ( 112 - <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> 123 + {({hovered, focused, pressed}) => ( 124 + <View 125 + style={[ 126 + a.flex_row, 127 + a.align_center, 128 + a.gap_xs, 129 + (hovered || focused || pressed) && native({opacity: 0.5}), 130 + style, 131 + ]}> 113 132 <Icon 114 - color={t.palette.contrast_400} 133 + color={ 134 + isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400 135 + } 115 136 width={16} 116 137 settings={settings} 117 138 /> ··· 119 140 style={[ 120 141 a.text_sm, 121 142 a.leading_tight, 122 - t.atoms.text_contrast_medium, 123 - hovered && a.underline, 143 + isThreadAuthor 144 + ? {color: t.palette.primary_500} 145 + : t.atoms.text_contrast_medium, 146 + (hovered || focused || pressed) && web(a.underline), 124 147 ]}> 125 148 {description} 126 149 </Text> 127 150 128 151 {isThreadAuthor && ( 129 - <PencilLine width={12} fill={t.palette.primary_500} /> 152 + <TinyChevronDownIcon width={8} fill={t.palette.primary_500} /> 130 153 )} 131 154 </View> 132 155 )} ··· 164 187 settings.length === 0 || 165 188 settings.every(setting => setting.type === 'everybody') 166 189 const isNobody = !!settings.find(gate => gate.type === 'nobody') 167 - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group 190 + const IconComponent = isEverybody 191 + ? EarthIcon 192 + : isNobody 193 + ? CircleBanSignIcon 194 + : GroupIcon 168 195 return <IconComponent fill={color} width={width} /> 169 196 } 170 197
+16 -4
src/components/activity-notifications/SubscribeProfileButton.tsx
··· 1 - import {useCallback} from 'react' 1 + import {useCallback, useEffect, useState} from 'react' 2 2 import {type ModerationOpts} from '@atproto/api' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' ··· 27 27 const subscribeDialogControl = useDialogControl() 28 28 const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] = 29 29 useActivitySubscriptionsNudged() 30 + const [showTooltip, setShowTooltip] = useState(false) 30 31 31 - const onDismissTooltip = () => { 32 + useEffect(() => { 33 + if (!activitySubscriptionsNudged) { 34 + const timeout = setTimeout(() => { 35 + setShowTooltip(true) 36 + }, 500) 37 + return () => clearTimeout(timeout) 38 + } 39 + }, [activitySubscriptionsNudged]) 40 + 41 + const onDismissTooltip = (visible: boolean) => { 42 + if (visible) return 43 + 44 + setShowTooltip(false) 32 45 setActivitySubscriptionsNudged(true) 33 46 } 34 47 ··· 56 69 return ( 57 70 <> 58 71 <Tooltip.Outer 59 - visible={!activitySubscriptionsNudged} 72 + visible={showTooltip} 60 73 onVisibleChange={onDismissTooltip} 61 74 position="bottom"> 62 75 <Tooltip.Target> ··· 65 78 testID="dmBtn" 66 79 size="small" 67 80 color="secondary" 68 - variant="solid" 69 81 shape="round" 70 82 label={_(msg`Get notified when ${name} posts`)} 71 83 onPress={wrappedOnPress}>
+393 -280
src/components/dialogs/PostInteractionSettingsDialog.tsx
··· 1 - import React from 'react' 2 - import {type StyleProp, View, type ViewStyle} from 'react-native' 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {LayoutAnimation, Text as NestedText, View} from 'react-native' 3 3 import { 4 4 type AppBskyFeedDefs, 5 5 type AppBskyFeedPostgate, 6 6 AtUri, 7 7 } from '@atproto/api' 8 - import {msg, Trans} from '@lingui/macro' 8 + import {msg, Plural, Trans} from '@lingui/macro' 9 9 import {useLingui} from '@lingui/react' 10 10 import {useQueryClient} from '@tanstack/react-query' 11 - import isEqual from 'lodash.isequal' 12 11 12 + import {useHaptics} from '#/lib/haptics' 13 13 import {logger} from '#/logger' 14 + import {isIOS} from '#/platform/detection' 14 15 import {STALE} from '#/state/queries' 15 16 import {useMyListsQuery} from '#/state/queries/my-lists' 16 17 import {useGetPost} from '#/state/queries/post' ··· 37 38 } from '#/state/queries/usePostThread' 38 39 import {useAgent, useSession} from '#/state/session' 39 40 import * as Toast from '#/view/com/util/Toast' 40 - import {atoms as a, useTheme} from '#/alf' 41 + import {UserAvatar} from '#/view/com/util/UserAvatar' 42 + import {atoms as a, useTheme, web} from '#/alf' 41 43 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 42 44 import * as Dialog from '#/components/Dialog' 43 - import {Divider} from '#/components/Divider' 44 45 import * as Toggle from '#/components/forms/Toggle' 45 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 46 + import { 47 + ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 48 + ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 49 + } from '#/components/icons/Chevron' 46 50 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 51 + import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 47 52 import {Loader} from '#/components/Loader' 48 53 import {Text} from '#/components/Typography' 49 54 ··· 52 57 onSave: () => void 53 58 isSaving?: boolean 54 59 60 + isDirty?: boolean 61 + persist?: boolean 62 + onChangePersist?: (v: boolean) => void 63 + 55 64 postgate: AppBskyFeedPostgate.Record 56 65 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 57 66 ··· 61 70 replySettingsDisabled?: boolean 62 71 } 63 72 73 + /** 74 + * Threadgate settings dialog. Used in the composer. 75 + */ 64 76 export function PostInteractionSettingsControlledDialog({ 65 77 control, 66 78 ...rest 67 79 }: PostInteractionSettingsFormProps & { 68 80 control: Dialog.DialogControlProps 69 81 }) { 70 - const t = useTheme() 71 - const {_} = useLingui() 72 - 73 82 return ( 74 - <Dialog.Outer control={control}> 83 + <Dialog.Outer 84 + control={control} 85 + nativeOptions={{ 86 + preventExpansion: true, 87 + preventDismiss: rest.isDirty && rest.persist, 88 + }}> 75 89 <Dialog.Handle /> 76 - <Dialog.ScrollableInner 77 - label={_(msg`Edit post interaction settings`)} 78 - style={[{maxWidth: 500}, a.w_full]}> 79 - <View style={[a.gap_md]}> 80 - <Header /> 81 - <PostInteractionSettingsForm {...rest} /> 82 - <Text 83 - style={[ 84 - a.pt_sm, 85 - a.text_sm, 86 - a.leading_snug, 87 - t.atoms.text_contrast_medium, 88 - ]}> 89 - <Trans> 90 - You can set default interaction settings in{' '} 91 - <Text style={[a.font_semi_bold, t.atoms.text_contrast_medium]}> 92 - Settings &rarr; Moderation &rarr; Interaction settings 93 - </Text> 94 - . 95 - </Trans> 96 - </Text> 97 - </View> 98 - <Dialog.Close /> 99 - </Dialog.ScrollableInner> 90 + <DialogInner {...rest} /> 100 91 </Dialog.Outer> 101 92 ) 102 93 } 103 94 104 - export function Header() { 95 + function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) { 96 + const {_} = useLingui() 97 + 105 98 return ( 106 - <View style={[a.gap_md, a.pb_sm]}> 107 - <Text style={[a.text_2xl, a.font_semi_bold]}> 108 - <Trans>Post interaction settings</Trans> 109 - </Text> 110 - <Text style={[a.text_md, a.pb_xs]}> 111 - <Trans>Customize who can interact with this post.</Trans> 112 - </Text> 113 - <Divider /> 114 - </View> 99 + <Dialog.ScrollableInner 100 + label={_(msg`Edit post interaction settings`)} 101 + style={[web({maxWidth: 400}), a.w_full]}> 102 + <Header /> 103 + <PostInteractionSettingsForm {...props} /> 104 + <Dialog.Close /> 105 + </Dialog.ScrollableInner> 115 106 ) 116 107 } 117 108 ··· 134 125 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 135 126 } 136 127 128 + /** 129 + * Threadgate settings dialog. Used in the thread. 130 + */ 137 131 export function PostInteractionSettingsDialog( 138 132 props: PostInteractionSettingsDialogProps, 139 133 ) { 140 134 const postThreadContext = usePostThreadContext() 141 135 return ( 142 - <Dialog.Outer control={props.control}> 136 + <Dialog.Outer 137 + control={props.control} 138 + nativeOptions={{preventExpansion: true}}> 143 139 <Dialog.Handle /> 144 140 <PostThreadContextProvider context={postThreadContext}> 145 141 <PostInteractionSettingsDialogControlledInner {...props} /> ··· 153 149 ) { 154 150 const {_} = useLingui() 155 151 const {currentAccount} = useSession() 156 - const [isSaving, setIsSaving] = React.useState(false) 152 + const [isSaving, setIsSaving] = useState(false) 157 153 158 154 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 159 155 useThreadgateViewQuery({postUri: props.rootPostUri}) ··· 165 161 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 166 162 167 163 const [editedPostgate, setEditedPostgate] = 168 - React.useState<AppBskyFeedPostgate.Record>() 164 + useState<AppBskyFeedPostgate.Record>() 169 165 const [editedAllowUISettings, setEditedAllowUISettings] = 170 - React.useState<ThreadgateAllowUISetting[]>() 166 + useState<ThreadgateAllowUISetting[]>() 171 167 172 168 const isLoading = isLoadingThreadgate || isLoadingPostgate 173 169 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 174 - const isThreadgateOwnedByViewer = React.useMemo(() => { 170 + const isThreadgateOwnedByViewer = useMemo(() => { 175 171 return currentAccount?.did === new AtUri(props.rootPostUri).host 176 172 }, [props.rootPostUri, currentAccount?.did]) 177 173 178 - const postgateValue = React.useMemo(() => { 174 + const postgateValue = useMemo(() => { 179 175 return ( 180 176 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 181 177 ) 182 178 }, [postgate, editedPostgate, props.postUri]) 183 - const allowUIValue = React.useMemo(() => { 179 + const allowUIValue = useMemo(() => { 184 180 return ( 185 181 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 186 182 ) 187 183 }, [threadgateView, editedAllowUISettings]) 188 184 189 - const onSave = React.useCallback(async () => { 185 + const onSave = useCallback(async () => { 190 186 if (!editedPostgate && !editedAllowUISettings) { 191 187 props.control.close() 192 188 return ··· 248 244 return ( 249 245 <Dialog.ScrollableInner 250 246 label={_(msg`Edit post interaction settings`)} 251 - style={[{maxWidth: 500}, a.w_full]}> 252 - <View style={[a.gap_md]}> 253 - <Header /> 254 - 255 - {isLoading ? ( 256 - <View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}> 257 - <Loader size="xl" /> 258 - </View> 259 - ) : ( 247 + style={[web({maxWidth: 400}), a.w_full]}> 248 + {isLoading ? ( 249 + <View 250 + style={[ 251 + a.flex_1, 252 + a.py_5xl, 253 + a.gap_md, 254 + a.align_center, 255 + a.justify_center, 256 + ]}> 257 + <Loader size="xl" /> 258 + <Text style={[a.italic, a.text_center]}> 259 + <Trans>Loading post interaction settings...</Trans> 260 + </Text> 261 + </View> 262 + ) : ( 263 + <> 264 + <Header /> 260 265 <PostInteractionSettingsForm 261 266 replySettingsDisabled={!isThreadgateOwnedByViewer} 262 267 isSaving={isSaving} ··· 266 271 threadgateAllowUISettings={allowUIValue} 267 272 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 268 273 /> 269 - )} 270 - </View> 274 + </> 275 + )} 276 + <Dialog.Close /> 271 277 </Dialog.ScrollableInner> 272 278 ) 273 279 } ··· 281 287 threadgateAllowUISettings, 282 288 onChangeThreadgateAllowUISettings, 283 289 replySettingsDisabled, 290 + isDirty, 291 + persist, 292 + onChangePersist, 284 293 }: PostInteractionSettingsFormProps) { 285 294 const t = useTheme() 286 295 const {_} = useLingui() 287 - const {data: lists} = useMyListsQuery('curate') 288 - const [quotesEnabled, setQuotesEnabled] = React.useState( 296 + const playHaptic = useHaptics() 297 + const [showLists, setShowLists] = useState(false) 298 + const { 299 + data: lists, 300 + isPending: isListsPending, 301 + isError: isListsError, 302 + } = useMyListsQuery('curate') 303 + const [quotesEnabled, setQuotesEnabled] = useState( 289 304 !( 290 305 postgate.embeddingRules && 291 306 postgate.embeddingRules.find( ··· 294 309 ), 295 310 ) 296 311 297 - const onPressAudience = (setting: ThreadgateAllowUISetting) => { 298 - // remove boolean values 299 - let newSelected: ThreadgateAllowUISetting[] = 300 - threadgateAllowUISettings.filter( 301 - v => v.type !== 'nobody' && v.type !== 'everybody', 302 - ) 303 - // toggle 304 - const i = newSelected.findIndex(v => isEqual(v, setting)) 305 - if (i === -1) { 306 - newSelected.push(setting) 307 - } else { 308 - newSelected.splice(i, 1) 309 - } 310 - if (newSelected.length === 0) { 311 - newSelected.push({type: 'everybody'}) 312 - } 313 - 314 - onChangeThreadgateAllowUISettings(newSelected) 315 - } 316 - 317 - const onChangeQuotesEnabled = React.useCallback( 312 + const onChangeQuotesEnabled = useCallback( 318 313 (enabled: boolean) => { 319 314 setQuotesEnabled(enabled) 320 315 onChangePostgate( ··· 330 325 const noOneCanReply = !!threadgateAllowUISettings.find( 331 326 v => v.type === 'nobody', 332 327 ) 328 + const everyoneCanReply = !!threadgateAllowUISettings.find( 329 + v => v.type === 'everybody', 330 + ) 331 + const numberOfListsSelected = threadgateAllowUISettings.filter( 332 + v => v.type === 'list', 333 + ).length 333 334 334 - return ( 335 - <View> 336 - <View style={[a.flex_1, a.gap_md]}> 337 - <View style={[a.gap_lg]}> 338 - <View style={[a.gap_sm]}> 339 - <Text style={[a.font_semi_bold, a.text_lg]}> 340 - <Trans>Quote settings</Trans> 341 - </Text> 335 + const toggleGroupValues = useMemo(() => { 336 + const values: string[] = [] 337 + for (const setting of threadgateAllowUISettings) { 338 + switch (setting.type) { 339 + case 'everybody': 340 + case 'nobody': 341 + // no granularity, early return with nothing 342 + return [] 343 + case 'followers': 344 + values.push('followers') 345 + break 346 + case 'following': 347 + values.push('following') 348 + break 349 + case 'mention': 350 + values.push('mention') 351 + break 352 + case 'list': 353 + values.push(`list:${setting.list}`) 354 + break 355 + default: 356 + break 357 + } 358 + } 359 + return values 360 + }, [threadgateAllowUISettings]) 342 361 343 - <Toggle.Item 344 - name="quoteposts" 345 - type="checkbox" 346 - label={ 347 - quotesEnabled 348 - ? _(msg`Click to disable quote posts of this post.`) 349 - : _(msg`Click to enable quote posts of this post.`) 350 - } 351 - value={quotesEnabled} 352 - onChange={onChangeQuotesEnabled} 353 - style={[a.justify_between, a.pt_xs]}> 354 - <Text style={[t.atoms.text_contrast_medium]}> 355 - <Trans>Allow quote posts</Trans> 356 - </Text> 357 - <Toggle.Switch /> 358 - </Toggle.Item> 359 - </View> 362 + const toggleGroupOnChange = (values: string[]) => { 363 + const settings: ThreadgateAllowUISetting[] = [] 360 364 361 - <Divider /> 365 + if (values.length === 0) { 366 + settings.push({type: 'everybody'}) 367 + } else { 368 + for (const value of values) { 369 + if (value.startsWith('list:')) { 370 + const listId = value.slice('list:'.length) 371 + settings.push({type: 'list', list: listId}) 372 + } else { 373 + settings.push({type: value as 'followers' | 'following' | 'mention'}) 374 + } 375 + } 376 + } 362 377 363 - {replySettingsDisabled && ( 364 - <View 365 - style={[ 366 - a.px_md, 367 - a.py_sm, 368 - a.rounded_sm, 369 - a.flex_row, 370 - a.align_center, 371 - a.gap_sm, 372 - t.atoms.bg_contrast_25, 373 - ]}> 374 - <CircleInfo fill={t.atoms.text_contrast_low.color} /> 375 - <Text 376 - style={[ 377 - a.flex_1, 378 - a.leading_snug, 379 - t.atoms.text_contrast_medium, 380 - ]}> 381 - <Trans> 382 - Reply settings are chosen by the author of the thread 383 - </Trans> 384 - </Text> 385 - </View> 386 - )} 378 + onChangeThreadgateAllowUISettings(settings) 379 + } 387 380 381 + return ( 382 + <View style={[a.flex_1, a.gap_lg]}> 383 + <View style={[a.gap_lg]}> 384 + {replySettingsDisabled && ( 388 385 <View 389 386 style={[ 387 + a.px_md, 388 + a.py_sm, 389 + a.rounded_sm, 390 + a.flex_row, 391 + a.align_center, 390 392 a.gap_sm, 391 - { 392 - opacity: replySettingsDisabled ? 0.3 : 1, 393 - }, 393 + t.atoms.bg_contrast_25, 394 394 ]}> 395 - <Text style={[a.font_semi_bold, a.text_lg]}> 396 - <Trans>Reply settings</Trans> 395 + <CircleInfo fill={t.atoms.text_contrast_low.color} /> 396 + <Text 397 + style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}> 398 + <Trans> 399 + Reply settings are chosen by the author of the thread 400 + </Trans> 397 401 </Text> 402 + </View> 403 + )} 398 404 399 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 400 - <Trans>Allow replies from:</Trans> 401 - </Text> 405 + <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}> 406 + <Text style={[a.text_md, a.font_medium]}> 407 + <Trans>Who can reply</Trans> 408 + </Text> 402 409 410 + <Toggle.Group 411 + label={_(msg`Set who can reply to your post`)} 412 + type="radio" 413 + maxSelections={1} 414 + disabled={replySettingsDisabled} 415 + values={ 416 + everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : [] 417 + } 418 + onChange={val => { 419 + if (val.includes('everyone')) { 420 + onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 421 + } else if (val.includes('nobody')) { 422 + onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 423 + } else { 424 + onChangeThreadgateAllowUISettings([{type: 'mention'}]) 425 + } 426 + }}> 403 427 <View style={[a.flex_row, a.gap_sm]}> 404 - <Selectable 405 - label={_(msg`Everybody`)} 406 - isSelected={ 407 - !!threadgateAllowUISettings.find(v => v.type === 'everybody') 408 - } 409 - onPress={() => 410 - onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 411 - } 412 - style={{flex: 1}} 413 - disabled={replySettingsDisabled} 414 - /> 415 - <Selectable 416 - label={_(msg`Nobody`)} 417 - isSelected={noOneCanReply} 418 - onPress={() => 419 - onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 420 - } 421 - style={{flex: 1}} 422 - disabled={replySettingsDisabled} 423 - /> 428 + <Toggle.Item 429 + name="everyone" 430 + type="checkbox" 431 + label={_(msg`Allow anyone to reply`)} 432 + style={[a.flex_1]}> 433 + {({selected}) => ( 434 + <Toggle.Panel active={selected}> 435 + <Toggle.Radio /> 436 + <Toggle.PanelText> 437 + <Trans>Anyone</Trans> 438 + </Toggle.PanelText> 439 + </Toggle.Panel> 440 + )} 441 + </Toggle.Item> 442 + <Toggle.Item 443 + name="nobody" 444 + type="checkbox" 445 + label={_(msg`Disable replies entirely`)} 446 + style={[a.flex_1]}> 447 + {({selected}) => ( 448 + <Toggle.Panel active={selected}> 449 + <Toggle.Radio /> 450 + <Toggle.PanelText> 451 + <Trans>Nobody</Trans> 452 + </Toggle.PanelText> 453 + </Toggle.Panel> 454 + )} 455 + </Toggle.Item> 424 456 </View> 457 + </Toggle.Group> 425 458 426 - {!noOneCanReply && ( 427 - <> 428 - <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 429 - <Trans>Or combine these options:</Trans> 430 - </Text> 459 + <Toggle.Group 460 + label={_( 461 + msg`Set precisely which groups of people can reply to your post`, 462 + )} 463 + values={toggleGroupValues} 464 + onChange={toggleGroupOnChange} 465 + disabled={replySettingsDisabled}> 466 + <Toggle.PanelGroup> 467 + <Toggle.Item 468 + name="followers" 469 + type="checkbox" 470 + label={_(msg`Allow your followers to reply`)} 471 + hitSlop={0}> 472 + {({selected}) => ( 473 + <Toggle.Panel active={selected} adjacent="trailing"> 474 + <Toggle.Checkbox /> 475 + <Toggle.PanelText> 476 + <Trans>Your followers</Trans> 477 + </Toggle.PanelText> 478 + </Toggle.Panel> 479 + )} 480 + </Toggle.Item> 481 + <Toggle.Item 482 + name="following" 483 + type="checkbox" 484 + label={_(msg`Allow people you follow to reply`)} 485 + hitSlop={0}> 486 + {({selected}) => ( 487 + <Toggle.Panel active={selected} adjacent="both"> 488 + <Toggle.Checkbox /> 489 + <Toggle.PanelText> 490 + <Trans>People you follow</Trans> 491 + </Toggle.PanelText> 492 + </Toggle.Panel> 493 + )} 494 + </Toggle.Item> 495 + <Toggle.Item 496 + name="mention" 497 + type="checkbox" 498 + label={_(msg`Allow people you mention to reply`)} 499 + hitSlop={0}> 500 + {({selected}) => ( 501 + <Toggle.Panel active={selected} adjacent="both"> 502 + <Toggle.Checkbox /> 503 + <Toggle.PanelText> 504 + <Trans>People you mention</Trans> 505 + </Toggle.PanelText> 506 + </Toggle.Panel> 507 + )} 508 + </Toggle.Item> 431 509 432 - <View style={[a.gap_sm]}> 433 - <Selectable 434 - label={_(msg`Mentioned users`)} 435 - isSelected={ 436 - !!threadgateAllowUISettings.find( 437 - v => v.type === 'mention', 438 - ) 439 - } 440 - onPress={() => onPressAudience({type: 'mention'})} 441 - disabled={replySettingsDisabled} 442 - /> 443 - <Selectable 444 - label={_(msg`Users you follow`)} 445 - isSelected={ 446 - !!threadgateAllowUISettings.find( 447 - v => v.type === 'following', 448 - ) 449 - } 450 - onPress={() => onPressAudience({type: 'following'})} 451 - disabled={replySettingsDisabled} 452 - /> 453 - <Selectable 454 - label={_(msg`Your followers`)} 455 - isSelected={ 456 - !!threadgateAllowUISettings.find( 457 - v => v.type === 'followers', 458 - ) 459 - } 460 - onPress={() => onPressAudience({type: 'followers'})} 461 - disabled={replySettingsDisabled} 510 + <Button 511 + label={ 512 + showLists 513 + ? _(msg`Hide lists`) 514 + : _(msg`Show lists of users to select from`) 515 + } 516 + accessibilityHint={_(msg`Toggle showing lists`)} 517 + accessibilityRole="togglebutton" 518 + hitSlop={0} 519 + onPress={() => { 520 + playHaptic('Light') 521 + if (isIOS && !showLists) { 522 + LayoutAnimation.configureNext({ 523 + ...LayoutAnimation.Presets.linear, 524 + duration: 175, 525 + }) 526 + } 527 + setShowLists(s => !s) 528 + }}> 529 + <Toggle.Panel 530 + active={numberOfListsSelected > 0} 531 + adjacent={showLists ? 'both' : 'leading'}> 532 + <Toggle.PanelText> 533 + {numberOfListsSelected === 0 ? ( 534 + <Trans>Select from your lists</Trans> 535 + ) : ( 536 + <Trans> 537 + Select from your lists{' '} 538 + <NestedText style={[a.font_normal, a.italic]}> 539 + <Plural 540 + value={numberOfListsSelected} 541 + other="(# selected)" 542 + /> 543 + </NestedText> 544 + </Trans> 545 + )} 546 + </Toggle.PanelText> 547 + <Toggle.PanelIcon 548 + icon={showLists ? ChevronUpIcon : ChevronDownIcon} 462 549 /> 463 - {lists && lists.length > 0 464 - ? lists.map(list => ( 465 - <Selectable 466 - key={list.uri} 467 - label={_(msg`Users in "${list.name}"`)} 468 - isSelected={ 469 - !!threadgateAllowUISettings.find( 470 - v => v.type === 'list' && v.list === list.uri, 471 - ) 472 - } 473 - onPress={() => 474 - onPressAudience({type: 'list', list: list.uri}) 475 - } 476 - disabled={replySettingsDisabled} 477 - /> 478 - )) 479 - : // No loading states to avoid jumps for the common case (no lists) 480 - null} 481 - </View> 482 - </> 483 - )} 484 - </View> 550 + </Toggle.Panel> 551 + </Button> 552 + {showLists && 553 + (isListsPending ? ( 554 + <Toggle.Panel> 555 + <Toggle.PanelText> 556 + <Trans>Loading lists...</Trans> 557 + </Toggle.PanelText> 558 + </Toggle.Panel> 559 + ) : isListsError ? ( 560 + <Toggle.Panel> 561 + <Toggle.PanelText> 562 + <Trans> 563 + An error occurred while loading your lists :/ 564 + </Trans> 565 + </Toggle.PanelText> 566 + </Toggle.Panel> 567 + ) : lists.length === 0 ? ( 568 + <Toggle.Panel> 569 + <Toggle.PanelText> 570 + <Trans>You don't have any lists yet.</Trans> 571 + </Toggle.PanelText> 572 + </Toggle.Panel> 573 + ) : ( 574 + lists.map((list, i) => ( 575 + <Toggle.Item 576 + key={list.uri} 577 + name={`list:${list.uri}`} 578 + type="checkbox" 579 + label={_(msg`Allow users in ${list.name} to reply`)} 580 + hitSlop={0}> 581 + {({selected}) => ( 582 + <Toggle.Panel 583 + active={selected} 584 + adjacent={ 585 + i === lists.length - 1 ? 'leading' : 'both' 586 + }> 587 + <Toggle.Checkbox /> 588 + <UserAvatar 589 + size={24} 590 + type="list" 591 + avatar={list.avatar} 592 + /> 593 + <Toggle.PanelText>{list.name}</Toggle.PanelText> 594 + </Toggle.Panel> 595 + )} 596 + </Toggle.Item> 597 + )) 598 + ))} 599 + </Toggle.PanelGroup> 600 + </Toggle.Group> 485 601 </View> 486 602 </View> 487 603 604 + <Toggle.Item 605 + name="quoteposts" 606 + type="checkbox" 607 + label={ 608 + quotesEnabled 609 + ? _(msg`Disable quote posts of this post.`) 610 + : _(msg`Enable quote posts of this post.`) 611 + } 612 + value={quotesEnabled} 613 + onChange={onChangeQuotesEnabled}> 614 + {({selected}) => ( 615 + <Toggle.Panel active={selected}> 616 + <Toggle.PanelText icon={QuoteIcon}> 617 + <Trans>Allow quote posts</Trans> 618 + </Toggle.PanelText> 619 + <Toggle.Switch /> 620 + </Toggle.Panel> 621 + )} 622 + </Toggle.Item> 623 + 624 + {typeof persist !== 'undefined' && ( 625 + <View style={[{minHeight: 24}, a.justify_center]}> 626 + {isDirty ? ( 627 + <Toggle.Item 628 + name="persist" 629 + type="checkbox" 630 + label={_(msg`Save these options for next time`)} 631 + value={persist} 632 + onChange={() => onChangePersist?.(!persist)}> 633 + <Toggle.Checkbox /> 634 + <Toggle.LabelText 635 + style={[a.text_md, a.font_normal, t.atoms.text]}> 636 + <Trans>Save these options for next time</Trans> 637 + </Toggle.LabelText> 638 + </Toggle.Item> 639 + ) : ( 640 + <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 641 + <Trans>These are your default settings</Trans> 642 + </Text> 643 + )} 644 + </View> 645 + )} 646 + 488 647 <Button 489 648 disabled={!canSave || isSaving} 490 649 label={_(msg`Save`)} 491 650 onPress={onSave} 492 651 color="primary" 493 - size="large" 494 - variant="solid" 495 - style={a.mt_xl}> 496 - <ButtonText>{_(msg`Save`)}</ButtonText> 497 - {isSaving && <ButtonIcon icon={Loader} position="right" />} 652 + size="large"> 653 + <ButtonText> 654 + <Trans>Save</Trans> 655 + </ButtonText> 656 + {isSaving && <ButtonIcon icon={Loader} />} 498 657 </Button> 499 658 </View> 500 659 ) 501 660 } 502 661 503 - function Selectable({ 504 - label, 505 - isSelected, 506 - onPress, 507 - style, 508 - disabled, 509 - }: { 510 - label: string 511 - isSelected: boolean 512 - onPress: () => void 513 - style?: StyleProp<ViewStyle> 514 - disabled?: boolean 515 - }) { 516 - const t = useTheme() 662 + function Header() { 517 663 return ( 518 - <Button 519 - disabled={disabled} 520 - onPress={onPress} 521 - label={label} 522 - accessibilityRole="checkbox" 523 - aria-checked={isSelected} 524 - accessibilityState={{ 525 - checked: isSelected, 526 - }} 527 - style={a.flex_1}> 528 - {({hovered, focused}) => ( 529 - <View 530 - style={[ 531 - a.flex_1, 532 - a.flex_row, 533 - a.align_center, 534 - a.justify_between, 535 - a.rounded_sm, 536 - a.p_md, 537 - {minHeight: 40}, // for consistency with checkmark icon visible or not 538 - t.atoms.bg_contrast_50, 539 - (hovered || focused) && t.atoms.bg_contrast_100, 540 - isSelected && { 541 - backgroundColor: t.palette.primary_100, 542 - }, 543 - style, 544 - ]}> 545 - <Text style={[a.text_sm, isSelected && a.font_semi_bold]}> 546 - {label} 547 - </Text> 548 - {isSelected ? ( 549 - <Check size="sm" fill={t.palette.primary_500} /> 550 - ) : ( 551 - <View /> 552 - )} 553 - </View> 554 - )} 555 - </Button> 664 + <View style={[a.pb_lg]}> 665 + <Text style={[a.text_2xl, a.font_bold]}> 666 + <Trans>Post interaction settings</Trans> 667 + </Text> 668 + </View> 556 669 ) 557 670 } 558 671 ··· 567 680 const agent = useAgent() 568 681 const getPost = useGetPost() 569 682 570 - return React.useCallback(async () => { 683 + return useCallback(async () => { 571 684 try { 572 685 await Promise.all([ 573 686 queryClient.prefetchQuery({
+157 -74
src/components/forms/Toggle.tsx src/components/forms/Toggle/index.tsx
··· 1 - import React from 'react' 2 - import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 - import Animated, {LinearTransition} from 'react-native-reanimated' 1 + import {createContext, useCallback, useContext, useMemo} from 'react' 2 + import { 3 + Pressable, 4 + type PressableProps, 5 + type StyleProp, 6 + View, 7 + type ViewStyle, 8 + } from 'react-native' 9 + import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 4 10 5 11 import {HITSLOP_10} from '#/lib/constants' 12 + import {useHaptics} from '#/lib/haptics' 6 13 import {isNative} from '#/platform/detection' 7 14 import { 8 15 atoms as a, 9 16 native, 17 + platform, 10 18 type TextStyleProp, 11 19 useTheme, 12 20 type ViewStyleProp, ··· 15 23 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 16 24 import {Text} from '#/components/Typography' 17 25 26 + export * from './Panel' 27 + 18 28 export type ItemState = { 19 29 name: string 20 30 selected: boolean ··· 25 35 focused: boolean 26 36 } 27 37 28 - const ItemContext = React.createContext<ItemState>({ 38 + const ItemContext = createContext<ItemState>({ 29 39 name: '', 30 40 selected: false, 31 41 disabled: false, ··· 36 46 }) 37 47 ItemContext.displayName = 'ToggleItemContext' 38 48 39 - const GroupContext = React.createContext<{ 49 + const GroupContext = createContext<{ 40 50 values: string[] 41 51 disabled: boolean 42 52 type: 'radio' | 'checkbox' ··· 70 80 onChange?: (selected: boolean) => void 71 81 isInvalid?: boolean 72 82 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 83 + hitSlop?: PressableProps['hitSlop'] 73 84 } 74 85 75 86 export function useItemContext() { 76 - return React.useContext(ItemContext) 87 + return useContext(ItemContext) 77 88 } 78 89 79 90 export function Group({ ··· 88 99 }: GroupProps) { 89 100 const groupRole = type === 'radio' ? 'radiogroup' : undefined 90 101 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 91 - const [maxReached, setMaxReached] = React.useState(false) 92 102 93 - const setFieldValue = React.useCallback< 103 + const setFieldValue = useCallback< 94 104 (props: {name: string; value: boolean}) => void 95 105 >( 96 106 ({name, value}) => { ··· 105 115 [type, onChange, values], 106 116 ) 107 117 108 - React.useEffect(() => { 109 - if (type === 'checkbox') { 110 - if ( 111 - maxSelections && 112 - values.length >= maxSelections && 113 - maxReached === false 114 - ) { 115 - setMaxReached(true) 116 - } else if ( 117 - maxSelections && 118 - values.length < maxSelections && 119 - maxReached === true 120 - ) { 121 - setMaxReached(false) 122 - } 123 - } 124 - }, [type, values.length, maxSelections, maxReached, setMaxReached]) 118 + const maxReached = !!( 119 + type === 'checkbox' && 120 + maxSelections && 121 + values.length >= maxSelections 122 + ) 125 123 126 - const context = React.useMemo( 124 + const context = useMemo( 127 125 () => ({ 128 126 values, 129 127 type, ··· 170 168 disabled: groupDisabled, 171 169 setFieldValue, 172 170 maxSelectionsReached, 173 - } = React.useContext(GroupContext) 171 + } = useContext(GroupContext) 174 172 const { 175 173 state: hovered, 176 174 onIn: onHoverIn, ··· 182 180 onOut: onPressOut, 183 181 } = useInteractionState() 184 182 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 183 + const playHaptic = useHaptics() 185 184 186 185 const role = groupType === 'radio' ? 'radio' : type 187 186 const selected = selectedValues.includes(name) || !!value 188 187 const disabled = 189 188 groupDisabled || itemDisabled || (!selected && maxSelectionsReached) 190 189 191 - const onPress = React.useCallback(() => { 190 + const onPress = useCallback(() => { 191 + playHaptic('Light') 192 192 const next = !selected 193 193 setFieldValue({name, value: next}) 194 194 onChange?.(next) 195 - }, [name, selected, onChange, setFieldValue]) 195 + }, [playHaptic, name, selected, onChange, setFieldValue]) 196 196 197 - const state = React.useMemo( 197 + const state = useMemo( 198 198 () => ({ 199 199 name, 200 200 selected, ··· 250 250 style={[ 251 251 a.font_semi_bold, 252 252 a.leading_tight, 253 + a.user_select_none, 253 254 { 254 - userSelect: 'none', 255 255 color: disabled 256 256 ? t.atoms.text_contrast_low.color 257 257 : t.atoms.text_contrast_high.color, ··· 287 287 288 288 if (selected) { 289 289 base.push({ 290 - backgroundColor: t.palette.primary_25, 290 + backgroundColor: t.palette.primary_500, 291 291 borderColor: t.palette.primary_500, 292 292 }) 293 293 294 294 if (hovered) { 295 295 baseHover.push({ 296 - backgroundColor: t.palette.primary_100, 297 - borderColor: t.palette.primary_600, 296 + backgroundColor: t.palette.primary_400, 297 + borderColor: t.palette.primary_400, 298 298 }) 299 299 } 300 300 } else { 301 + base.push({ 302 + backgroundColor: t.palette.contrast_25, 303 + borderColor: t.palette.contrast_100, 304 + }) 305 + 301 306 if (hovered) { 302 307 baseHover.push({ 303 308 backgroundColor: t.palette.contrast_50, 304 - borderColor: t.palette.contrast_500, 309 + borderColor: t.palette.contrast_200, 305 310 }) 306 311 } 307 312 } ··· 318 323 borderColor: t.palette.negative_600, 319 324 }) 320 325 } 326 + 327 + if (selected) { 328 + base.push({ 329 + backgroundColor: t.palette.negative_500, 330 + borderColor: t.palette.negative_500, 331 + }) 332 + 333 + if (hovered) { 334 + baseHover.push({ 335 + backgroundColor: t.palette.negative_400, 336 + borderColor: t.palette.negative_400, 337 + }) 338 + } 339 + } 321 340 } 322 341 323 342 if (disabled) { ··· 325 344 backgroundColor: t.palette.contrast_100, 326 345 borderColor: t.palette.contrast_400, 327 346 }) 347 + 348 + if (selected) { 349 + base.push({ 350 + backgroundColor: t.palette.primary_100, 351 + borderColor: t.palette.contrast_400, 352 + }) 353 + } 328 354 } 329 355 330 356 return { ··· 350 376 style={[ 351 377 a.justify_center, 352 378 a.align_center, 353 - a.rounded_xs, 354 379 t.atoms.border_contrast_high, 380 + a.transition_color, 355 381 { 356 382 borderWidth: 1, 357 383 height: 24, 358 384 width: 24, 385 + borderRadius: 6, 359 386 }, 360 387 baseStyles, 361 388 hovered ? baseHoverStyles : {}, 362 389 ]}> 363 - {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null} 390 + {selected && <Checkmark width={14} fill={t.palette.white} />} 364 391 </View> 365 392 ) 366 393 } 367 394 368 395 export function Switch() { 369 396 const t = useTheme() 370 - const {selected, hovered, focused, disabled, isInvalid} = useItemContext() 371 - const {baseStyles, baseHoverStyles, indicatorStyles} = 372 - createSharedToggleStyles({ 373 - theme: t, 374 - hovered, 375 - focused, 376 - selected, 377 - disabled, 378 - isInvalid, 379 - }) 397 + const {selected, hovered, disabled, isInvalid} = useItemContext() 398 + const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => { 399 + const base: ViewStyle[] = [] 400 + const baseHover: ViewStyle[] = [] 401 + const indicator: ViewStyle[] = [] 402 + 403 + if (selected) { 404 + base.push({ 405 + backgroundColor: t.palette.primary_500, 406 + }) 407 + 408 + if (hovered) { 409 + baseHover.push({ 410 + backgroundColor: t.palette.primary_400, 411 + }) 412 + } 413 + } else { 414 + base.push({ 415 + backgroundColor: t.palette.contrast_200, 416 + }) 417 + 418 + if (hovered) { 419 + baseHover.push({ 420 + backgroundColor: t.palette.contrast_100, 421 + }) 422 + } 423 + } 424 + 425 + if (isInvalid) { 426 + base.push({ 427 + backgroundColor: t.palette.negative_200, 428 + }) 429 + 430 + if (hovered) { 431 + baseHover.push({ 432 + backgroundColor: t.palette.negative_100, 433 + }) 434 + } 435 + 436 + if (selected) { 437 + base.push({ 438 + backgroundColor: t.palette.negative_500, 439 + }) 440 + 441 + if (hovered) { 442 + baseHover.push({ 443 + backgroundColor: t.palette.negative_400, 444 + }) 445 + } 446 + } 447 + } 448 + 449 + if (disabled) { 450 + base.push({ 451 + backgroundColor: t.palette.contrast_50, 452 + }) 453 + 454 + if (selected) { 455 + base.push({ 456 + backgroundColor: t.palette.primary_100, 457 + }) 458 + } 459 + } 460 + 461 + return { 462 + baseStyles: base, 463 + baseHoverStyles: disabled ? [] : baseHover, 464 + indicatorStyles: indicator, 465 + } 466 + }, [t, hovered, disabled, selected, isInvalid]) 467 + 380 468 return ( 381 469 <View 382 470 style={[ 383 471 a.relative, 384 472 a.rounded_full, 385 473 t.atoms.bg, 386 - t.atoms.border_contrast_high, 387 474 { 388 - borderWidth: 1, 389 - height: 24, 390 - width: 36, 475 + height: 28, 476 + width: 48, 391 477 padding: 3, 392 478 }, 479 + a.transition_color, 393 480 baseStyles, 394 481 hovered ? baseHoverStyles : {}, 395 482 ]}> 396 483 <Animated.View 397 - layout={LinearTransition.duration(100)} 484 + layout={LinearTransition.duration( 485 + platform({ 486 + web: 100, 487 + default: 200, 488 + }), 489 + ).easing(Easing.inOut(Easing.cubic))} 398 490 style={[ 399 491 a.rounded_full, 400 492 { 401 - height: 16, 402 - width: 16, 493 + backgroundColor: t.palette.white, 494 + height: 22, 495 + width: 22, 403 496 }, 404 - selected 405 - ? { 406 - backgroundColor: t.palette.primary_500, 407 - alignSelf: 'flex-end', 408 - } 409 - : { 410 - backgroundColor: t.palette.contrast_400, 411 - alignSelf: 'flex-start', 412 - }, 497 + selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'}, 413 498 indicatorStyles, 414 499 ]} 415 500 /> ··· 420 505 export function Radio() { 421 506 const t = useTheme() 422 507 const {selected, hovered, focused, disabled, isInvalid} = 423 - React.useContext(ItemContext) 508 + useContext(ItemContext) 424 509 const {baseStyles, baseHoverStyles, indicatorStyles} = 425 510 createSharedToggleStyles({ 426 511 theme: t, ··· 437 522 a.align_center, 438 523 a.rounded_full, 439 524 t.atoms.border_contrast_high, 525 + a.transition_color, 440 526 { 441 527 borderWidth: 1, 442 - height: 24, 443 - width: 24, 528 + height: 25, 529 + width: 25, 530 + margin: -1, 444 531 }, 445 532 baseStyles, 446 533 hovered ? baseHoverStyles : {}, 447 534 ]}> 448 - {selected ? ( 535 + {selected && ( 449 536 <View 450 537 style={[ 451 538 a.absolute, 452 539 a.rounded_full, 453 - {height: 16, width: 16}, 454 - selected 455 - ? { 456 - backgroundColor: t.palette.primary_500, 457 - } 458 - : {}, 540 + {height: 12, width: 12}, 541 + {backgroundColor: t.palette.white}, 459 542 indicatorStyles, 460 543 ]} 461 544 /> 462 - ) : null} 545 + )} 463 546 </View> 464 547 ) 465 548 }
+120
src/components/forms/Toggle/Panel.tsx
··· 1 + import {createContext, useContext} from 'react' 2 + import {View, type ViewStyle} from 'react-native' 3 + 4 + import {atoms as a, tokens, useTheme} from '#/alf' 5 + import {type Props as SVGIconProps} from '#/components/icons/common' 6 + import {Text} from '#/components/Typography' 7 + 8 + const PanelContext = createContext<{active: boolean}>({active: false}) 9 + 10 + /** 11 + * A nice container for Toggles. See the Threadgate dialog for an example. 12 + */ 13 + export function Panel({ 14 + children, 15 + active = false, 16 + adjacent, 17 + }: { 18 + children: React.ReactNode 19 + active?: boolean 20 + adjacent?: 'leading' | 'trailing' | 'both' 21 + }) { 22 + const t = useTheme() 23 + 24 + const leading = adjacent === 'leading' || adjacent === 'both' 25 + const trailing = adjacent === 'trailing' || adjacent === 'both' 26 + const rounding = { 27 + borderTopLeftRadius: leading 28 + ? tokens.borderRadius.xs 29 + : tokens.borderRadius.md, 30 + borderTopRightRadius: leading 31 + ? tokens.borderRadius.xs 32 + : tokens.borderRadius.md, 33 + borderBottomLeftRadius: trailing 34 + ? tokens.borderRadius.xs 35 + : tokens.borderRadius.md, 36 + borderBottomRightRadius: trailing 37 + ? tokens.borderRadius.xs 38 + : tokens.borderRadius.md, 39 + } satisfies ViewStyle 40 + 41 + return ( 42 + <View 43 + style={[ 44 + a.w_full, 45 + a.flex_row, 46 + a.align_center, 47 + a.gap_sm, 48 + a.px_md, 49 + a.py_md, 50 + {minHeight: tokens.space._2xl + tokens.space.md * 2}, 51 + rounding, 52 + active 53 + ? {backgroundColor: t.palette.primary_50} 54 + : t.atoms.bg_contrast_50, 55 + ]}> 56 + <PanelContext value={{active}}>{children}</PanelContext> 57 + </View> 58 + ) 59 + } 60 + 61 + export function PanelText({ 62 + children, 63 + icon, 64 + }: { 65 + children: React.ReactNode 66 + icon?: React.ComponentType<SVGIconProps> 67 + }) { 68 + const t = useTheme() 69 + const ctx = useContext(PanelContext) 70 + 71 + const text = ( 72 + <Text 73 + style={[ 74 + a.text_md, 75 + a.flex_1, 76 + ctx.active 77 + ? [a.font_medium, t.atoms.text] 78 + : [t.atoms.text_contrast_medium], 79 + ]}> 80 + {children} 81 + </Text> 82 + ) 83 + 84 + if (icon) { 85 + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 86 + return ( 87 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}> 88 + <PanelIcon icon={icon} /> 89 + {text} 90 + </View> 91 + ) 92 + } 93 + 94 + return text 95 + } 96 + 97 + export function PanelIcon({ 98 + icon: Icon, 99 + }: { 100 + icon: React.ComponentType<SVGIconProps> 101 + }) { 102 + const t = useTheme() 103 + const ctx = useContext(PanelContext) 104 + return ( 105 + <Icon 106 + style={[ 107 + ctx.active ? t.atoms.text : t.atoms.text_contrast_medium, 108 + a.flex_shrink_0, 109 + ]} 110 + size="md" 111 + /> 112 + ) 113 + } 114 + 115 + /** 116 + * A group of panels. TODO: auto-leading/trailing 117 + */ 118 + export function PanelGroup({children}: {children: React.ReactNode}) { 119 + return <View style={[a.w_full, a.gap_2xs]}>{children}</View> 120 + }
+7
src/components/icons/Chevron.tsx
··· 19 19 export const ChevronTopBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 20 20 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', 21 21 }) 22 + 23 + /** 24 + * NOTE: Use with size `2xs` 25 + */ 26 + export const TinyChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({ 27 + 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', 28 + })
+1
src/components/icons/common.tsx
··· 13 13 } & Omit<SvgProps, 'style' | 'size'> 14 14 15 15 export const sizes = { 16 + '2xs': 8, 16 17 xs: 12, 17 18 sm: 16, 18 19 md: 20,
+1 -1
src/components/verification/VerificationsDialog.tsx
··· 34 34 verificationState: FullVerificationState 35 35 }) { 36 36 return ( 37 - <Dialog.Outer control={control}> 37 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 38 38 <Dialog.Handle /> 39 39 <Inner 40 40 control={control}
+6 -2
src/lib/strings/time.ts
··· 1 1 import {type I18n} from '@lingui/core' 2 2 3 - export function niceDate(i18n: I18n, date: number | string | Date) { 3 + export function niceDate( 4 + i18n: I18n, 5 + date: number | string | Date, 6 + dateStyle: 'short' | 'medium' | 'long' | 'full' = 'long', 7 + ) { 4 8 const d = new Date(date) 5 9 6 10 return i18n.date(d, { 7 - dateStyle: 'long', 11 + dateStyle, 8 12 timeStyle: 'short', 9 13 }) 10 14 }
+1 -1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 570 570 <BackdatedPostIndicator post={post} /> 571 571 <View style={[a.flex_row, a.align_center, a.flex_wrap, a.gap_sm]}> 572 572 <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 573 - {niceDate(i18n, post.indexedAt)} 573 + {niceDate(i18n, post.indexedAt, 'medium')} 574 574 </Text> 575 575 {isRootPost && ( 576 576 <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
+1
src/screens/Settings/components/SettingsList.tsx
··· 194 194 * also so that we can calculate transforms. 195 195 */ 196 196 const iconSize = { 197 + '2xs': 8, 197 198 xs: 12, 198 199 sm: 16, 199 200 md: 20,
+6 -2
src/state/global-gesture-events/index.tsx
··· 1 1 import {createContext, useContext, useMemo, useRef, useState} from 'react' 2 - import {View} from 'react-native' 2 + import {type StyleProp, View, type ViewStyle} from 'react-native' 3 3 import { 4 4 Gesture, 5 5 GestureDetector, ··· 29 29 30 30 export function GlobalGestureEventsProvider({ 31 31 children, 32 + style, 32 33 }: { 33 34 children: React.ReactNode 35 + style?: StyleProp<ViewStyle> 34 36 }) { 35 37 const refCount = useRef(0) 36 38 const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), []) ··· 73 75 return ( 74 76 <Context.Provider value={ctx}> 75 77 <GestureDetector gesture={gesture}> 76 - <View collapsable={false}>{children}</View> 78 + <View collapsable={false} style={style}> 79 + {children} 80 + </View> 77 81 </GestureDetector> 78 82 </Context.Provider> 79 83 )
+9 -1
src/state/queries/post-interaction-settings.ts
··· 4 4 import {preferencesQueryKey} from '#/state/queries/preferences' 5 5 import {useAgent} from '#/state/session' 6 6 7 - export function usePostInteractionSettingsMutation() { 7 + export function usePostInteractionSettingsMutation({ 8 + onError, 9 + onSettled, 10 + }: { 11 + onError?: (error: Error) => void 12 + onSettled?: () => void 13 + } = {}) { 8 14 const qc = useQueryClient() 9 15 const agent = useAgent() 10 16 return useMutation({ ··· 16 22 queryKey: preferencesQueryKey, 17 23 }) 18 24 }, 25 + onError, 26 + onSettled, 19 27 }) 20 28 }
+9
src/storage/hooks/threadgate-nudged.ts
··· 1 + import {device, useStorage} from '#/storage' 2 + 3 + export function useThreadgateNudged() { 4 + const [threadgateNudged = false, setThreadgateNudged] = useStorage(device, [ 5 + 'threadgateNudged', 6 + ]) 7 + 8 + return [threadgateNudged, setThreadgateNudged] as const 9 + }
+1
src/storage/schema.ts
··· 37 37 devMode: boolean 38 38 demoMode: boolean 39 39 activitySubscriptionsNudged?: boolean 40 + threadgateNudged?: boolean 40 41 41 42 /** 42 43 * Policy update overlays. New IDs are required for each new announcement.
+4 -9
src/view/com/composer/labels/LabelsBtn.tsx
··· 10 10 type SelfLabel, 11 11 } from '#/lib/moderation' 12 12 import {isWeb} from '#/platform/detection' 13 - import {atoms as a, native, useTheme, web} from '#/alf' 13 + import {atoms as a, useTheme, web} from '#/alf' 14 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 15 import * as Dialog from '#/components/Dialog' 16 16 import * as Toggle from '#/components/forms/Toggle' 17 17 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 18 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' 18 19 import {Shield_Stroke2_Corner0_Rounded} from '#/components/icons/Shield' 19 20 import {Text} from '#/components/Typography' 20 21 ··· 49 50 return ( 50 51 <> 51 52 <Button 52 - variant="solid" 53 53 color="secondary" 54 54 size="small" 55 55 testID="labelsBtn" ··· 60 60 label={_(msg`Content warnings`)} 61 61 accessibilityHint={_( 62 62 msg`Opens a dialog to add a content warning to your post`, 63 - )} 64 - style={[ 65 - native({ 66 - paddingHorizontal: 8, 67 - paddingVertical: 6, 68 - }), 69 - ]}> 63 + )}> 70 64 <ButtonIcon icon={hasLabel ? Check : Shield_Stroke2_Corner0_Rounded} /> 71 65 <ButtonText numberOfLines={1}> 72 66 {labels.length > 0 ? ( ··· 75 69 <Trans>Labels</Trans> 76 70 )} 77 71 </ButtonText> 72 + <ButtonIcon icon={TinyChevronIcon} size="2xs" /> 78 73 </Button> 79 74 80 75 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
+126 -25
src/view/com/composer/threadgate/ThreadgateBtn.tsx
··· 1 + import {useEffect, useMemo, useState} from 'react' 1 2 import {Keyboard, type StyleProp, type ViewStyle} from 'react-native' 2 3 import {type AnimatedStyle} from 'react-native-reanimated' 3 4 import {type AppBskyFeedPostgate} from '@atproto/api' 4 - import {msg} from '@lingui/macro' 5 + import {msg, Trans} from '@lingui/macro' 5 6 import {useLingui} from '@lingui/react' 7 + import deepEqual from 'lodash.isequal' 6 8 9 + import {isNetworkError} from '#/lib/strings/errors' 10 + import {logger} from '#/logger' 7 11 import {isNative} from '#/platform/detection' 8 - import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' 9 - import {native} from '#/alf' 12 + import {usePostInteractionSettingsMutation} from '#/state/queries/post-interaction-settings' 13 + import {createPostgateRecord} from '#/state/queries/postgate/util' 14 + import {usePreferencesQuery} from '#/state/queries/preferences' 15 + import { 16 + type ThreadgateAllowUISetting, 17 + threadgateAllowUISettingToAllowRecordValue, 18 + threadgateRecordToAllowUISetting, 19 + } from '#/state/queries/threadgate' 10 20 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 21 import * as Dialog from '#/components/Dialog' 12 22 import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog' 13 - import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 14 - import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 23 + import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronIcon} from '#/components/icons/Chevron' 24 + import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 25 + import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 26 + import * as Tooltip from '#/components/Tooltip' 27 + import {Text} from '#/components/Typography' 28 + import {useThreadgateNudged} from '#/storage/hooks/threadgate-nudged' 15 29 16 30 export function ThreadgateBtn({ 17 31 postgate, ··· 29 43 }) { 30 44 const {_} = useLingui() 31 45 const control = Dialog.useDialogControl() 46 + const [threadgateNudged, setThreadgateNudged] = useThreadgateNudged() 47 + const [showTooltip, setShowTooltip] = useState(false) 48 + 49 + useEffect(() => { 50 + if (!threadgateNudged) { 51 + const timeout = setTimeout(() => { 52 + setShowTooltip(true) 53 + }, 1000) 54 + return () => clearTimeout(timeout) 55 + } 56 + }, [threadgateNudged]) 57 + 58 + const onDismissTooltip = (visible: boolean) => { 59 + if (visible) return 60 + setThreadgateNudged(true) 61 + setShowTooltip(false) 62 + } 63 + 64 + const {data: preferences} = usePreferencesQuery() 65 + const [persist, setPersist] = useState(false) 32 66 33 67 const onPress = () => { 34 68 if (isNative && Keyboard.isVisible()) { 35 69 Keyboard.dismiss() 36 70 } 37 71 72 + setShowTooltip(false) 73 + setThreadgateNudged(true) 74 + 38 75 control.open() 39 76 } 40 77 78 + const prefThreadgateAllowUISettings = threadgateRecordToAllowUISetting({ 79 + $type: 'app.bsky.feed.threadgate', 80 + post: '', 81 + createdAt: new Date().toISOString(), 82 + allow: preferences?.postInteractionSettings.threadgateAllowRules, 83 + }) 84 + const prefPostgate = createPostgateRecord({ 85 + post: '', 86 + embeddingRules: 87 + preferences?.postInteractionSettings?.postgateEmbeddingRules || [], 88 + }) 89 + 90 + const isDirty = useMemo(() => { 91 + const everybody = [{type: 'everybody'}] 92 + return ( 93 + !deepEqual( 94 + threadgateAllowUISettings, 95 + prefThreadgateAllowUISettings ?? everybody, 96 + ) || 97 + !deepEqual(postgate.embeddingRules, prefPostgate?.embeddingRules ?? []) 98 + ) 99 + }, [ 100 + prefThreadgateAllowUISettings, 101 + prefPostgate, 102 + threadgateAllowUISettings, 103 + postgate, 104 + ]) 105 + 106 + const {mutate: persistChanges, isPending: isSaving} = 107 + usePostInteractionSettingsMutation({ 108 + onError: err => { 109 + if (!isNetworkError(err)) { 110 + logger.error('Failed to persist threadgate settings', { 111 + safeMessage: err, 112 + }) 113 + } 114 + }, 115 + onSettled: () => { 116 + control.close(() => { 117 + setPersist(false) 118 + }) 119 + }, 120 + }) 121 + 41 122 const anyoneCanReply = 42 123 threadgateAllowUISettings.length === 1 && 43 124 threadgateAllowUISettings[0].type === 'everybody' ··· 50 131 51 132 return ( 52 133 <> 53 - <Button 54 - variant="solid" 55 - color="secondary" 56 - size="small" 57 - testID="openReplyGateButton" 58 - onPress={onPress} 59 - label={label} 60 - accessibilityHint={_( 61 - msg`Opens a dialog to choose who can reply to this thread`, 62 - )} 63 - style={[ 64 - native({ 65 - paddingHorizontal: 8, 66 - paddingVertical: 6, 67 - }), 68 - ]}> 69 - <ButtonIcon icon={anyoneCanInteract ? Earth : Group} /> 70 - <ButtonText numberOfLines={1}>{label}</ButtonText> 71 - </Button> 134 + <Tooltip.Outer 135 + visible={showTooltip} 136 + onVisibleChange={onDismissTooltip} 137 + position="top"> 138 + <Tooltip.Target> 139 + <Button 140 + color={showTooltip ? 'primary_subtle' : 'secondary'} 141 + size="small" 142 + testID="openReplyGateButton" 143 + onPress={onPress} 144 + label={label} 145 + accessibilityHint={_( 146 + msg`Opens a dialog to choose who can interact with this post`, 147 + )}> 148 + <ButtonIcon icon={anyoneCanInteract ? EarthIcon : GroupIcon} /> 149 + <ButtonText numberOfLines={1}>{label}</ButtonText> 150 + <ButtonIcon icon={TinyChevronIcon} size="2xs" /> 151 + </Button> 152 + </Tooltip.Target> 153 + <Tooltip.TextBubble> 154 + <Text> 155 + <Trans>Psst! You can edit who can interact with this post.</Trans> 156 + </Text> 157 + </Tooltip.TextBubble> 158 + </Tooltip.Outer> 159 + 72 160 <PostInteractionSettingsControlledDialog 73 161 control={control} 74 162 onSave={() => { 75 - control.close() 163 + if (persist) { 164 + persistChanges({ 165 + threadgateAllowRules: threadgateAllowUISettingToAllowRecordValue( 166 + threadgateAllowUISettings, 167 + ), 168 + postgateEmbeddingRules: postgate.embeddingRules ?? [], 169 + }) 170 + } else { 171 + control.close() 172 + } 76 173 }} 174 + isSaving={isSaving} 77 175 postgate={postgate} 78 176 onChangePostgate={onChangePostgate} 79 177 threadgateAllowUISettings={threadgateAllowUISettings} 80 178 onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings} 179 + isDirty={isDirty} 180 + persist={persist} 181 + onChangePersist={setPersist} 81 182 /> 82 183 </> 83 184 )
+9
src/view/screens/Storybook/Forms.tsx
··· 155 155 </View> 156 156 </Toggle.Group> 157 157 158 + <Toggle.Item name="d" disabled value label="Click me"> 159 + <Toggle.Switch /> 160 + <Toggle.LabelText>Click me</Toggle.LabelText> 161 + </Toggle.Item> 162 + <Toggle.Item name="d" disabled value isInvalid label="Click me"> 163 + <Toggle.Switch /> 164 + <Toggle.LabelText>Click me</Toggle.LabelText> 165 + </Toggle.Item> 166 + 158 167 <Toggle.Group 159 168 label="Toggle" 160 169 type="checkbox"
+15 -12
src/view/shell/Composer.ios.tsx
··· 3 3 4 4 import {useDialogStateControlContext} from '#/state/dialogs' 5 5 import {useComposerState} from '#/state/shell/composer' 6 + import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' 6 7 import {atoms as a, useTheme} from '#/alf' 7 - import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 8 + import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' 8 9 9 10 export function Composer({}: {winHeight: number}) { 10 11 const {setFullyExpandedCount} = useDialogStateControlContext() ··· 33 34 animationType="slide" 34 35 onRequestClose={() => ref.current?.onPressCancel()}> 35 36 <View style={[t.atoms.bg, a.flex_1]}> 36 - <ComposePost 37 - cancelRef={ref} 38 - replyTo={state?.replyTo} 39 - onPost={state?.onPost} 40 - onPostSuccess={state?.onPostSuccess} 41 - quote={state?.quote} 42 - mention={state?.mention} 43 - text={state?.text} 44 - imageUris={state?.imageUris} 45 - videoUri={state?.videoUri} 46 - /> 37 + <TooltipSheetCompatProvider> 38 + <ComposePost 39 + cancelRef={ref} 40 + replyTo={state?.replyTo} 41 + onPost={state?.onPost} 42 + onPostSuccess={state?.onPostSuccess} 43 + quote={state?.quote} 44 + mention={state?.mention} 45 + text={state?.text} 46 + imageUris={state?.imageUris} 47 + videoUri={state?.videoUri} 48 + /> 49 + </TooltipSheetCompatProvider> 47 50 </View> 48 51 </Modal> 49 52 )