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

Configure Feed

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

at main 567 lines 13 kB view raw
1import {createContext, useCallback, useContext, useMemo} from 'react' 2import { 3 Pressable, 4 type PressableProps, 5 type StyleProp, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import Animated, {Easing, LinearTransition} from 'react-native-reanimated' 10 11import {HITSLOP_10} from '#/lib/constants' 12import {useHaptics} from '#/lib/haptics' 13import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 14import { 15 atoms as a, 16 native, 17 platform, 18 type TextStyleProp, 19 useTheme, 20 type ViewStyleProp, 21} from '#/alf' 22import {useInteractionState} from '#/components/hooks/useInteractionState' 23import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check' 24import {Text} from '#/components/Typography' 25import {IS_NATIVE} from '#/env' 26 27export * from './Panel' 28 29export type ItemState = { 30 name: string 31 selected: boolean 32 disabled: boolean 33 isInvalid: boolean 34 hovered: boolean 35 pressed: boolean 36 focused: boolean 37} 38 39const ItemContext = createContext<ItemState>({ 40 name: '', 41 selected: false, 42 disabled: false, 43 isInvalid: false, 44 hovered: false, 45 pressed: false, 46 focused: false, 47}) 48ItemContext.displayName = 'ToggleItemContext' 49 50const GroupContext = createContext<{ 51 values: string[] 52 disabled: boolean 53 type: 'radio' | 'checkbox' 54 maxSelectionsReached: boolean 55 setFieldValue: (props: {name: string; value: boolean}) => void 56}>({ 57 type: 'checkbox', 58 values: [], 59 disabled: false, 60 maxSelectionsReached: false, 61 setFieldValue: () => {}, 62}) 63GroupContext.displayName = 'ToggleGroupContext' 64 65export type GroupProps = React.PropsWithChildren<{ 66 type?: 'radio' | 'checkbox' 67 values: string[] 68 maxSelections?: number 69 disabled?: boolean 70 onChange: (value: string[]) => void 71 label: string 72 style?: StyleProp<ViewStyle> 73}> 74 75export type ItemProps = ViewStyleProp & { 76 type?: 'radio' | 'checkbox' 77 name: string 78 label: string 79 value?: boolean 80 disabled?: boolean 81 onChange?: (selected: boolean) => void 82 isInvalid?: boolean 83 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 84 hitSlop?: PressableProps['hitSlop'] 85} 86 87export function useItemContext() { 88 return useContext(ItemContext) 89} 90 91export function Group({ 92 children, 93 values: providedValues, 94 onChange, 95 disabled = false, 96 type = 'checkbox', 97 maxSelections, 98 label, 99 style, 100}: GroupProps) { 101 const groupRole = type === 'radio' ? 'radiogroup' : undefined 102 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues 103 104 const setFieldValue = useCallback< 105 (props: {name: string; value: boolean}) => void 106 >( 107 ({name, value}) => { 108 if (type === 'checkbox') { 109 const pruned = values.filter(v => v !== name) 110 const next = value ? pruned.concat(name) : pruned 111 onChange(next) 112 } else { 113 onChange([name]) 114 } 115 }, 116 [type, onChange, values], 117 ) 118 119 const maxReached = !!( 120 type === 'checkbox' && 121 maxSelections && 122 values.length >= maxSelections 123 ) 124 125 const context = useMemo( 126 () => ({ 127 values, 128 type, 129 disabled, 130 maxSelectionsReached: maxReached, 131 setFieldValue, 132 }), 133 [values, disabled, type, maxReached, setFieldValue], 134 ) 135 136 return ( 137 <GroupContext.Provider value={context}> 138 <View 139 style={[a.w_full, style]} 140 role={groupRole} 141 {...(groupRole === 'radiogroup' 142 ? { 143 'aria-label': label, 144 accessibilityLabel: label, 145 accessibilityRole: groupRole, 146 } 147 : {})}> 148 {children} 149 </View> 150 </GroupContext.Provider> 151 ) 152} 153 154export function Item({ 155 children, 156 name, 157 value = false, 158 disabled: itemDisabled = false, 159 onChange, 160 isInvalid, 161 style, 162 type = 'checkbox', 163 label, 164 ...rest 165}: ItemProps) { 166 const { 167 values: selectedValues, 168 type: groupType, 169 disabled: groupDisabled, 170 setFieldValue, 171 maxSelectionsReached, 172 } = useContext(GroupContext) 173 const { 174 state: hovered, 175 onIn: onHoverIn, 176 onOut: onHoverOut, 177 } = useInteractionState() 178 const { 179 state: pressed, 180 onIn: onPressIn, 181 onOut: onPressOut, 182 } = useInteractionState() 183 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 184 const playHaptic = useHaptics() 185 186 const role = groupType === 'radio' ? 'radio' : type 187 const selected = selectedValues.includes(name) || !!value 188 const disabled = 189 groupDisabled || itemDisabled || (!selected && maxSelectionsReached) 190 191 const onPress = useCallback(() => { 192 playHaptic('Light') 193 const next = !selected 194 setFieldValue({name, value: next}) 195 onChange?.(next) 196 }, [playHaptic, name, selected, onChange, setFieldValue]) 197 198 const state = useMemo( 199 () => ({ 200 name, 201 selected, 202 disabled: disabled ?? false, 203 isInvalid: isInvalid ?? false, 204 hovered, 205 pressed, 206 focused, 207 }), 208 [name, selected, disabled, hovered, pressed, focused, isInvalid], 209 ) 210 211 return ( 212 <ItemContext.Provider value={state}> 213 <Pressable 214 accessibilityHint={undefined} // optional 215 hitSlop={HITSLOP_10} 216 {...rest} 217 disabled={disabled} 218 aria-disabled={disabled ?? false} 219 aria-checked={selected} 220 aria-invalid={isInvalid} 221 aria-label={label} 222 role={role} 223 accessibilityRole={role} 224 accessibilityState={{ 225 disabled: disabled ?? false, 226 selected: selected, 227 }} 228 accessibilityLabel={label} 229 onPress={onPress} 230 onHoverIn={onHoverIn} 231 onHoverOut={onHoverOut} 232 onPressIn={onPressIn} 233 onPressOut={onPressOut} 234 onFocus={onFocus} 235 onBlur={onBlur} 236 style={[a.flex_row, a.align_center, a.gap_sm, style]}> 237 {typeof children === 'function' ? children(state) : children} 238 </Pressable> 239 </ItemContext.Provider> 240 ) 241} 242 243export function LabelText({ 244 children, 245 style, 246}: React.PropsWithChildren<TextStyleProp>) { 247 const t = useTheme() 248 const {disabled} = useItemContext() 249 return ( 250 <Text 251 style={[ 252 a.font_semi_bold, 253 a.leading_tight, 254 a.user_select_none, 255 { 256 color: disabled 257 ? t.atoms.text_contrast_low.color 258 : t.atoms.text_contrast_high.color, 259 }, 260 native({ 261 paddingTop: 2, 262 }), 263 style, 264 ]}> 265 {children} 266 </Text> 267 ) 268} 269 270// TODO(eric) refactor to memoize styles without knowledge of state 271export function createSharedToggleStyles({ 272 theme: t, 273 hovered, 274 selected, 275 disabled, 276 isInvalid, 277}: { 278 theme: ReturnType<typeof useTheme> 279 selected: boolean 280 hovered: boolean 281 focused: boolean 282 disabled: boolean 283 isInvalid: boolean 284}) { 285 const base: ViewStyle[] = [] 286 const baseHover: ViewStyle[] = [] 287 const indicator: ViewStyle[] = [] 288 289 if (selected) { 290 base.push({ 291 backgroundColor: t.palette.primary_500, 292 borderColor: t.palette.primary_500, 293 }) 294 295 if (hovered) { 296 baseHover.push({ 297 backgroundColor: t.palette.primary_400, 298 borderColor: t.palette.primary_400, 299 }) 300 } 301 } else { 302 base.push({ 303 backgroundColor: t.palette.contrast_25, 304 borderColor: t.palette.contrast_100, 305 }) 306 307 if (hovered) { 308 baseHover.push({ 309 backgroundColor: t.palette.contrast_50, 310 borderColor: t.palette.contrast_200, 311 }) 312 } 313 } 314 315 if (isInvalid) { 316 base.push({ 317 backgroundColor: t.palette.negative_25, 318 borderColor: t.palette.negative_300, 319 }) 320 321 if (hovered) { 322 baseHover.push({ 323 backgroundColor: t.palette.negative_25, 324 borderColor: t.palette.negative_600, 325 }) 326 } 327 328 if (selected) { 329 base.push({ 330 backgroundColor: t.palette.negative_500, 331 borderColor: t.palette.negative_500, 332 }) 333 334 if (hovered) { 335 baseHover.push({ 336 backgroundColor: t.palette.negative_400, 337 borderColor: t.palette.negative_400, 338 }) 339 } 340 } 341 } 342 343 if (disabled) { 344 base.push({ 345 backgroundColor: t.palette.contrast_100, 346 borderColor: t.palette.contrast_400, 347 }) 348 349 if (selected) { 350 base.push({ 351 backgroundColor: t.palette.primary_100, 352 borderColor: t.palette.contrast_400, 353 }) 354 } 355 } 356 357 return { 358 baseStyles: base, 359 baseHoverStyles: disabled ? [] : baseHover, 360 indicatorStyles: indicator, 361 } 362} 363 364export function Checkbox() { 365 const t = useTheme() 366 const {selected, hovered, focused, disabled, isInvalid} = useItemContext() 367 const {baseStyles, baseHoverStyles} = createSharedToggleStyles({ 368 theme: t, 369 hovered, 370 focused, 371 selected, 372 disabled, 373 isInvalid, 374 }) 375 return ( 376 <View 377 style={[ 378 a.justify_center, 379 a.align_center, 380 t.atoms.border_contrast_high, 381 a.transition_color, 382 { 383 borderWidth: 1, 384 height: 24, 385 width: 24, 386 borderRadius: 6, 387 }, 388 baseStyles, 389 hovered ? baseHoverStyles : {}, 390 ]}> 391 {selected && <Checkmark width={14} fill={t.palette.white} />} 392 </View> 393 ) 394} 395 396export function Switch() { 397 const t = useTheme() 398 const {selected, hovered, disabled, isInvalid} = useItemContext() 399 const enableSquareButtons = useEnableSquareButtons() 400 const {baseStyles, baseHoverStyles, indicatorStyles} = useMemo(() => { 401 const base: ViewStyle[] = [] 402 const baseHover: ViewStyle[] = [] 403 const indicator: ViewStyle[] = [] 404 405 if (selected) { 406 base.push({ 407 backgroundColor: t.palette.primary_500, 408 }) 409 410 if (hovered) { 411 baseHover.push({ 412 backgroundColor: t.palette.primary_400, 413 }) 414 } 415 } else { 416 base.push({ 417 backgroundColor: t.palette.contrast_200, 418 }) 419 420 if (hovered) { 421 baseHover.push({ 422 backgroundColor: t.palette.contrast_100, 423 }) 424 } 425 } 426 427 if (isInvalid) { 428 base.push({ 429 backgroundColor: t.palette.negative_200, 430 }) 431 432 if (hovered) { 433 baseHover.push({ 434 backgroundColor: t.palette.negative_100, 435 }) 436 } 437 438 if (selected) { 439 base.push({ 440 backgroundColor: t.palette.negative_500, 441 }) 442 443 if (hovered) { 444 baseHover.push({ 445 backgroundColor: t.palette.negative_400, 446 }) 447 } 448 } 449 } 450 451 if (disabled) { 452 base.push({ 453 backgroundColor: t.palette.contrast_50, 454 }) 455 456 if (selected) { 457 base.push({ 458 backgroundColor: t.palette.primary_100, 459 }) 460 } 461 } 462 463 return { 464 baseStyles: base, 465 baseHoverStyles: disabled ? [] : baseHover, 466 indicatorStyles: indicator, 467 } 468 }, [t, hovered, disabled, selected, isInvalid]) 469 470 return ( 471 <View 472 style={[ 473 a.relative, 474 enableSquareButtons ? a.rounded_sm : a.rounded_full, 475 t.atoms.bg, 476 { 477 height: 28, 478 width: 48, 479 padding: 3, 480 }, 481 a.transition_color, 482 baseStyles, 483 hovered ? baseHoverStyles : {}, 484 ]}> 485 <Animated.View 486 layout={LinearTransition.duration( 487 platform({ 488 web: 100, 489 default: 200, 490 }), 491 ).easing(Easing.inOut(Easing.cubic))} 492 style={[ 493 enableSquareButtons ? a.rounded_sm : a.rounded_full, 494 { 495 backgroundColor: t.palette.white, 496 height: 22, 497 width: 22, 498 }, 499 selected ? {alignSelf: 'flex-end'} : {alignSelf: 'flex-start'}, 500 indicatorStyles, 501 ]} 502 /> 503 </View> 504 ) 505} 506 507export function Radio() { 508 const props = useContext(ItemContext) 509 510 return <BaseRadio {...props} /> 511} 512 513export function BaseRadio({ 514 hovered, 515 focused, 516 selected, 517 disabled, 518 isInvalid, 519}: Pick< 520 ItemState, 521 'hovered' | 'focused' | 'selected' | 'disabled' | 'isInvalid' 522>) { 523 const t = useTheme() 524 const enableSquareButtons = useEnableSquareButtons() 525 const {baseStyles, baseHoverStyles, indicatorStyles} = 526 createSharedToggleStyles({ 527 theme: t, 528 hovered, 529 focused, 530 selected, 531 disabled, 532 isInvalid, 533 }) 534 535 return ( 536 <View 537 style={[ 538 a.justify_center, 539 a.align_center, 540 enableSquareButtons ? a.rounded_sm : a.rounded_full, 541 t.atoms.border_contrast_high, 542 a.transition_color, 543 { 544 borderWidth: 1, 545 height: 25, 546 width: 25, 547 margin: -1, 548 }, 549 baseStyles, 550 hovered ? baseHoverStyles : {}, 551 ]}> 552 {selected && ( 553 <View 554 style={[ 555 a.absolute, 556 enableSquareButtons ? a.rounded_sm : a.rounded_full, 557 {height: 12, width: 12}, 558 {backgroundColor: t.palette.white}, 559 indicatorStyles, 560 ]} 561 /> 562 )} 563 </View> 564 ) 565} 566 567export const Platform = IS_NATIVE ? Switch : Checkbox