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 491 lines 12 kB view raw
1import { 2 Children, 3 type ComponentType, 4 createContext, 5 type ForwardedRef, 6 isValidElement, 7 type PropsWithChildren, 8 type RefObject, 9 useContext, 10 useMemo, 11 useRef, 12} from 'react' 13import { 14 type AccessibilityProps, 15 StyleSheet, 16 TextInput, 17 type TextInputProps, 18 type TextStyle, 19 View, 20 type ViewStyle, 21} from 'react-native' 22 23import {HITSLOP_20} from '#/lib/constants' 24import {mergeRefs} from '#/lib/merge-refs' 25import { 26 android, 27 applyFonts, 28 atoms as a, 29 platform, 30 type TextStyleProp, 31 tokens, 32 useAlf, 33 useTheme, 34 utils, 35 web, 36} from '#/alf' 37import {useInteractionState} from '#/components/hooks/useInteractionState' 38import {type Props as SVGIconProps} from '#/components/icons/common' 39import {Text} from '#/components/Typography' 40import {IS_WEB} from '#/env' 41 42const Context = createContext<{ 43 inputRef: RefObject<TextInput | null> | null 44 isInvalid: boolean 45 hovered: boolean 46 onHoverIn: () => void 47 onHoverOut: () => void 48 focused: boolean 49 onFocus: () => void 50 onBlur: () => void 51}>({ 52 inputRef: null, 53 isInvalid: false, 54 hovered: false, 55 onHoverIn: () => {}, 56 onHoverOut: () => {}, 57 focused: false, 58 onFocus: () => {}, 59 onBlur: () => {}, 60}) 61Context.displayName = 'TextFieldContext' 62 63export type RootProps = PropsWithChildren<{isInvalid?: boolean} & TextStyleProp> 64 65export function Root({children, isInvalid = false, style}: RootProps) { 66 const inputRef = useRef<TextInput>(null) 67 const { 68 state: hovered, 69 onIn: onHoverIn, 70 onOut: onHoverOut, 71 } = useInteractionState() 72 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 73 74 const context = useMemo( 75 () => ({ 76 inputRef, 77 hovered, 78 onHoverIn, 79 onHoverOut, 80 focused, 81 onFocus, 82 onBlur, 83 isInvalid, 84 }), 85 [ 86 inputRef, 87 hovered, 88 onHoverIn, 89 onHoverOut, 90 focused, 91 onFocus, 92 onBlur, 93 isInvalid, 94 ], 95 ) 96 97 // Check if any child has multiline prop 98 const hasMultiline = useMemo(() => { 99 let found = false 100 Children.forEach(children, child => { 101 if ( 102 isValidElement(child) && 103 (child.props as {multiline?: boolean})?.multiline 104 ) { 105 found = true 106 } 107 }) 108 return found 109 }, [children]) 110 111 return ( 112 <Context.Provider value={context}> 113 <View 114 style={[ 115 a.flex_row, 116 a.align_center, 117 a.relative, 118 a.w_full, 119 !(hasMultiline && IS_WEB) && a.px_md, 120 style, 121 ]} 122 {...web({ 123 onClick: () => inputRef.current?.focus(), 124 onMouseOver: onHoverIn, 125 onMouseOut: onHoverOut, 126 })}> 127 {children} 128 </View> 129 </Context.Provider> 130 ) 131} 132 133export function useSharedInputStyles() { 134 const t = useTheme() 135 return useMemo(() => { 136 const hover: ViewStyle[] = [ 137 { 138 borderColor: t.palette.contrast_100, 139 }, 140 ] 141 const focus: ViewStyle[] = [ 142 { 143 backgroundColor: t.palette.primary_25, 144 borderColor: t.palette.primary_500, 145 }, 146 ] 147 const error: ViewStyle[] = [ 148 { 149 backgroundColor: t.palette.negative_25, 150 borderColor: t.palette.negative_300, 151 }, 152 ] 153 const errorHover: ViewStyle[] = [ 154 { 155 backgroundColor: t.palette.negative_25, 156 borderColor: t.palette.negative_500, 157 }, 158 ] 159 160 return { 161 chromeHover: StyleSheet.flatten(hover), 162 chromeFocus: StyleSheet.flatten(focus), 163 chromeError: StyleSheet.flatten(error), 164 chromeErrorHover: StyleSheet.flatten(errorHover), 165 } 166 }, [t]) 167} 168 169export type InputProps = Omit< 170 TextInputProps, 171 'value' | 'onChangeText' | 'placeholder' 172> & { 173 label: string 174 /** 175 * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible. 176 * 177 * See https://github.com/facebook/react-native-website/pull/4247 178 * 179 * Note: This guidance no longer applies once we migrate to the New Architecture! 180 */ 181 value?: string 182 onChangeText?: (value: string) => void 183 isInvalid?: boolean 184 inputRef?: RefObject<TextInput | null> | ForwardedRef<TextInput> 185 /** 186 * Note: this currently falls back to the label if not specified. However, 187 * most new designs have no placeholder. We should eventually remove this fallback 188 * behaviour, but for now just pass `null` if you want no placeholder -sfn 189 */ 190 placeholder?: string | null | undefined 191} 192 193export function createInput(Component: typeof TextInput) { 194 return function Input({ 195 label, 196 placeholder, 197 value, 198 onChangeText, 199 onFocus, 200 onBlur, 201 isInvalid, 202 inputRef, 203 style, 204 ...rest 205 }: InputProps) { 206 const t = useTheme() 207 const {fonts} = useAlf() 208 const ctx = useContext(Context) 209 const withinRoot = Boolean(ctx.inputRef) 210 211 const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = 212 useSharedInputStyles() 213 214 if (!withinRoot) { 215 return ( 216 <Root isInvalid={isInvalid}> 217 <Input 218 label={label} 219 placeholder={placeholder} 220 value={value} 221 onChangeText={onChangeText} 222 isInvalid={isInvalid} 223 {...rest} 224 /> 225 </Root> 226 ) 227 } 228 229 const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) 230 231 const flattened = StyleSheet.flatten([ 232 a.relative, 233 a.z_20, 234 a.flex_1, 235 a.text_md, 236 t.atoms.text, 237 a.px_xs, 238 { 239 // paddingVertical doesn't work w/multiline - esb 240 lineHeight: a.text_md.fontSize * 1.2, 241 textAlignVertical: rest.multiline ? 'top' : undefined, 242 minHeight: rest.multiline ? 80 : undefined, 243 minWidth: 0, 244 paddingTop: 13, 245 paddingBottom: 13, 246 }, 247 android({ 248 paddingTop: 8, 249 paddingBottom: 9, 250 }), 251 /* 252 * Margins are needed here to avoid autofill background overlapping the 253 * top and bottom borders - esb 254 */ 255 web({ 256 paddingTop: 11, 257 paddingBottom: 11, 258 marginTop: 2, 259 marginBottom: 2, 260 }), 261 rest.multiline && 262 web({ 263 resize: 'vertical', 264 fieldSizing: 'content', 265 paddingLeft: 16, 266 paddingRight: 16, 267 }), 268 style, 269 ]) 270 271 applyFonts(flattened, fonts.family) 272 273 // should always be defined on `typography` 274 // @ts-ignore 275 if (flattened.fontSize) { 276 // @ts-ignore 277 flattened.fontSize = Math.round( 278 // @ts-ignore 279 flattened.fontSize * fonts.scaleMultiplier, 280 ) 281 } 282 283 return ( 284 <> 285 <Component 286 accessibilityHint={undefined} 287 hitSlop={HITSLOP_20} 288 selectionColor={utils.alpha(t.palette.primary_500, 0.4)} 289 cursorColor={t.palette.primary_500} 290 selectionHandleColor={t.palette.primary_500} 291 {...rest} 292 accessibilityLabel={label} 293 ref={refs} 294 value={value} 295 onChangeText={onChangeText} 296 onFocus={e => { 297 ctx.onFocus() 298 onFocus?.(e) 299 }} 300 onBlur={e => { 301 ctx.onBlur() 302 onBlur?.(e) 303 }} 304 placeholder={placeholder === null ? undefined : placeholder || label} 305 placeholderTextColor={t.palette.contrast_500} 306 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 307 style={flattened} 308 /> 309 310 <View 311 style={[ 312 a.z_10, 313 a.absolute, 314 a.inset_0, 315 {borderRadius: 10}, 316 t.atoms.bg_contrast_50, 317 {borderColor: 'transparent', borderWidth: 1}, 318 ctx.hovered ? chromeHover : {}, 319 ctx.focused ? chromeFocus : {}, 320 ctx.isInvalid || isInvalid ? chromeError : {}, 321 (ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused) 322 ? chromeErrorHover 323 : {}, 324 ]} 325 /> 326 </> 327 ) 328 } 329} 330 331export const Input = createInput(TextInput) 332 333export function LabelText({ 334 nativeID, 335 children, 336}: PropsWithChildren<{nativeID?: string}>) { 337 const t = useTheme() 338 return ( 339 <Text 340 nativeID={nativeID} 341 style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}> 342 {children} 343 </Text> 344 ) 345} 346 347export function Icon({icon: Comp}: {icon: ComponentType<SVGIconProps>}) { 348 const t = useTheme() 349 const ctx = useContext(Context) 350 const {hover, focus, errorHover, errorFocus} = useMemo(() => { 351 const hover: TextStyle[] = [ 352 { 353 color: t.palette.contrast_800, 354 }, 355 ] 356 const focus: TextStyle[] = [ 357 { 358 color: t.palette.primary_500, 359 }, 360 ] 361 const errorHover: TextStyle[] = [ 362 { 363 color: t.palette.negative_500, 364 }, 365 ] 366 const errorFocus: TextStyle[] = [ 367 { 368 color: t.palette.negative_500, 369 }, 370 ] 371 372 return { 373 hover, 374 focus, 375 errorHover, 376 errorFocus, 377 } 378 }, [t]) 379 380 return ( 381 <View style={[a.z_20, a.pr_xs]}> 382 <Comp 383 size="md" 384 style={[ 385 {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0}, 386 ctx.hovered ? hover : {}, 387 ctx.focused ? focus : {}, 388 ctx.isInvalid && ctx.hovered ? errorHover : {}, 389 ctx.isInvalid && ctx.focused ? errorFocus : {}, 390 ]} 391 /> 392 </View> 393 ) 394} 395 396export function SuffixText({ 397 children, 398 label, 399 accessibilityHint, 400 style, 401}: PropsWithChildren< 402 TextStyleProp & { 403 label: string 404 accessibilityHint?: AccessibilityProps['accessibilityHint'] 405 } 406>) { 407 const t = useTheme() 408 const ctx = useContext(Context) 409 return ( 410 <Text 411 accessibilityLabel={label} 412 accessibilityHint={accessibilityHint} 413 numberOfLines={1} 414 style={[ 415 a.z_20, 416 a.pr_sm, 417 a.text_md, 418 t.atoms.text_contrast_medium, 419 a.pointer_events_none, 420 web([{marginTop: -2}, a.leading_snug]), 421 (ctx.hovered || ctx.focused) && {color: t.palette.contrast_800}, 422 style, 423 ]}> 424 {children} 425 </Text> 426 ) 427} 428 429export function GhostText({ 430 children, 431 value, 432}: { 433 children: string 434 value: string 435}) { 436 const t = useTheme() 437 // eslint-disable-next-line bsky-internal/avoid-unwrapped-text 438 return ( 439 <View 440 style={[ 441 a.pointer_events_none, 442 a.absolute, 443 a.z_10, 444 { 445 paddingLeft: platform({ 446 native: 447 // input padding 448 tokens.space.md + 449 // icon 450 tokens.space.xl + 451 // icon padding 452 tokens.space.xs + 453 // text input padding 454 tokens.space.xs, 455 web: 456 // icon 457 tokens.space.xl + 458 // icon padding 459 tokens.space.xs + 460 // text input padding 461 tokens.space.xs, 462 }), 463 }, 464 web(a.pr_md), 465 a.overflow_hidden, 466 a.max_w_full, 467 ]} 468 aria-hidden={true} 469 accessibilityElementsHidden 470 importantForAccessibility="no-hide-descendants"> 471 <Text 472 style={[ 473 {color: 'transparent'}, 474 a.text_md, 475 {lineHeight: a.text_md.fontSize * 1.1875}, 476 a.w_full, 477 ]} 478 numberOfLines={1}> 479 {children} 480 <Text 481 style={[ 482 t.atoms.text_contrast_low, 483 a.text_md, 484 {lineHeight: a.text_md.fontSize * 1.1875}, 485 ]}> 486 {value} 487 </Text> 488 </Text> 489 </View> 490 ) 491}