Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 287 lines 7.1 kB view raw
1import { 2 createContext, 3 useCallback, 4 useContext, 5 useLayoutEffect, 6 useMemo, 7 useState, 8} from 'react' 9import {type StyleProp, View, type ViewStyle} from 'react-native' 10import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 11 12import {useHaptics} from '#/lib/haptics' 13import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 14import {atoms as a, native, platform, useTheme} from '#/alf' 15import { 16 Button, 17 type ButtonProps, 18 ButtonText, 19 type ButtonTextProps, 20} from '../Button' 21 22const InternalContext = createContext<{ 23 type: 'tabs' | 'radio' 24 size: 'small' | 'large' 25 selectedValue: string 26 selectedPosition: {width: number; x: number} | null 27 onSelectValue: ( 28 value: string, 29 position: {width: number; x: number} | null, 30 ) => void 31 updatePosition: (position: {width: number; x: number}) => void 32} | null>(null) 33 34/** 35 * Segmented control component. 36 * 37 * @example 38 * ```tsx 39 * <SegmentedControl.Root value={value} onChange={setValue}> 40 * <SegmentedControl.Item value="one"> 41 * <SegmentedControl.ItemText value="one"> 42 * One 43 * </SegmentedControl.ItemText> 44 * </SegmentedControl.Item> 45 * <SegmentedControl.Item value="two"> 46 * <SegmentedControl.ItemText value="two"> 47 * Two 48 * </SegmentedControl.ItemText> 49 * </SegmentedControl.Item> 50 * </SegmentedControl.Root> 51 * ``` 52 */ 53export function Root<T extends string>({ 54 label, 55 type = 'radio', 56 size = 'large', 57 value, 58 onChange, 59 children, 60 style, 61 accessibilityHint, 62}: { 63 label: string 64 type: 'tabs' | 'radio' 65 size?: 'small' | 'large' 66 value: T 67 onChange: (value: T) => void 68 children: React.ReactNode 69 style?: StyleProp<ViewStyle> 70 accessibilityHint?: string 71}) { 72 const t = useTheme() 73 const [selectedPosition, setSelectedPosition] = useState<{ 74 width: number 75 x: number 76 } | null>(null) 77 78 const contextValue = useMemo(() => { 79 return { 80 type, 81 size, 82 selectedValue: value, 83 selectedPosition, 84 onSelectValue: ( 85 val: string, 86 position: {width: number; x: number} | null, 87 ) => { 88 onChange(val as T) 89 if (position) setSelectedPosition(position) 90 }, 91 updatePosition: (position: {width: number; x: number}) => { 92 setSelectedPosition(currPos => { 93 if ( 94 currPos && 95 currPos.width === position.width && 96 currPos.x === position.x 97 ) { 98 return currPos 99 } 100 return position 101 }) 102 }, 103 } 104 }, [value, selectedPosition, setSelectedPosition, onChange, type, size]) 105 106 return ( 107 <View 108 accessibilityLabel={label} 109 accessibilityHint={accessibilityHint ?? ''} 110 style={[ 111 a.w_full, 112 a.flex_1, 113 a.relative, 114 a.flex_row, 115 t.atoms.bg_contrast_50, 116 {borderRadius: 14}, 117 a.curve_continuous, 118 a.p_xs, 119 style, 120 ]} 121 role={type === 'tabs' ? 'tablist' : 'radiogroup'}> 122 {selectedPosition !== null && ( 123 <Slider x={selectedPosition.x} width={selectedPosition.width} /> 124 )} 125 <InternalContext.Provider value={contextValue}> 126 {children} 127 </InternalContext.Provider> 128 </View> 129 ) 130} 131 132const InternalItemContext = createContext<{ 133 active: boolean 134 pressed: boolean 135 hovered: boolean 136 focused: boolean 137} | null>(null) 138 139export function Item({ 140 value, 141 style, 142 children, 143 onPress: onPressProp, 144 ...props 145}: {value: string; children: React.ReactNode} & Omit<ButtonProps, 'children'>) { 146 const playHaptic = useHaptics() 147 const [position, setPosition] = useState<{x: number; width: number} | null>( 148 null, 149 ) 150 151 const ctx = useContext(InternalContext) 152 if (!ctx) 153 throw new Error( 154 'SegmentedControl.Item must be used within a SegmentedControl.Root', 155 ) 156 157 const active = ctx.selectedValue === value 158 159 // update position if change was external, and not due to onPress 160 const needsUpdate = 161 active && 162 position && 163 (ctx.selectedPosition?.x !== position.x || 164 ctx.selectedPosition?.width !== position.width) 165 166 // can't wait for `useEffectEvent` 167 const update = useNonReactiveCallback(() => { 168 if (position) ctx.updatePosition(position) 169 }) 170 171 useLayoutEffect(() => { 172 if (needsUpdate) { 173 update() 174 } 175 }, [needsUpdate, update]) 176 177 const onPress = useCallback( 178 (evt: any) => { 179 playHaptic('Light') 180 ctx.onSelectValue(value, position) 181 onPressProp?.(evt) 182 }, 183 [ctx, value, position, onPressProp, playHaptic], 184 ) 185 186 return ( 187 <View 188 style={[a.flex_1, a.flex_row]} 189 onLayout={evt => { 190 const measuredPosition = { 191 x: evt.nativeEvent.layout.x, 192 width: evt.nativeEvent.layout.width, 193 } 194 if (!ctx.selectedPosition && active) { 195 ctx.onSelectValue(value, measuredPosition) 196 } 197 setPosition(measuredPosition) 198 }}> 199 <Button 200 {...props} 201 onPress={onPress} 202 role={ctx.type === 'tabs' ? 'tab' : 'radio'} 203 accessibilityState={{selected: active}} 204 style={[ 205 a.flex_1, 206 a.bg_transparent, 207 a.px_sm, 208 a.py_xs, 209 {minHeight: ctx.size === 'large' ? 40 : 32}, 210 style, 211 ]}> 212 {({pressed, hovered, focused}) => ( 213 <InternalItemContext.Provider 214 value={{active, pressed, hovered, focused}}> 215 {children} 216 </InternalItemContext.Provider> 217 )} 218 </Button> 219 </View> 220 ) 221} 222 223export function ItemText({style, ...props}: ButtonTextProps) { 224 const t = useTheme() 225 const ctx = useContext(InternalItemContext) 226 if (!ctx) 227 throw new Error( 228 'SegmentedControl.ItemText must be used within a SegmentedControl.Item', 229 ) 230 return ( 231 <ButtonText 232 {...props} 233 style={[ 234 a.text_center, 235 a.text_md, 236 a.font_medium, 237 a.px_xs, 238 ctx.active 239 ? t.atoms.text 240 : ctx.focused || ctx.hovered || ctx.pressed 241 ? t.atoms.text_contrast_medium 242 : t.atoms.text_contrast_low, 243 style, 244 ]} 245 /> 246 ) 247} 248 249function Slider({x, width}: {x: number; width: number}) { 250 const t = useTheme() 251 252 return ( 253 <Animated.View 254 layout={native(LinearTransition.easing(Easing.out(Easing.exp)))} 255 style={[ 256 a.absolute, 257 a.curve_continuous, 258 t.atoms.bg, 259 { 260 top: 4, 261 bottom: 4, 262 left: 0, 263 width, 264 borderRadius: 10, 265 }, 266 // TODO: new arch supports boxShadow on native 267 // in the meantime this is an attempt to get close 268 platform({ 269 web: { 270 boxShadow: '0px 2px 4px 0px #0000000D', 271 }, 272 ios: { 273 shadowColor: '#000', 274 shadowOffset: {width: 0, height: 2}, 275 shadowOpacity: 0x0d / 0xff, 276 shadowRadius: 4, 277 }, 278 android: {elevation: 0.25}, 279 }), 280 platform({ 281 native: [{left: x}], 282 web: [{transform: [{translateX: x}]}, a.transition_transform], 283 }), 284 ]} 285 /> 286 ) 287}