Bluesky app fork with some witchin' additions 💫
at feat/tealfm 544 lines 14 kB view raw
1import { 2 Fragment, 3 useCallback, 4 useLayoutEffect, 5 useMemo, 6 useRef, 7 useState, 8} from 'react' 9import {TextInput, View} from 'react-native' 10import {moderateProfile, type ModerationOpts} from '@atproto/api' 11import {msg, Trans} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {sanitizeDisplayName} from '#/lib/strings/display-names' 15import {sanitizeHandle} from '#/lib/strings/handles' 16import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 17import {useModerationOpts} from '#/state/preferences/moderation-opts' 18import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 19import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 20import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 21import {useSession} from '#/state/session' 22import {type ListMethods} from '#/view/com/util/List' 23import {android, atoms as a, native, useTheme, web} from '#/alf' 24import {Button, ButtonIcon} from '#/components/Button' 25import * as Dialog from '#/components/Dialog' 26import {canBeMessaged} from '#/components/dms/util' 27import {useInteractionState} from '#/components/hooks/useInteractionState' 28import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 29import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30import * as ProfileCard from '#/components/ProfileCard' 31import {Text} from '#/components/Typography' 32import {IS_WEB} from '#/env' 33import type * as bsky from '#/types/bsky' 34 35export type ProfileItem = { 36 type: 'profile' 37 key: string 38 profile: bsky.profile.AnyProfileView 39} 40 41type EmptyItem = { 42 type: 'empty' 43 key: string 44 message: string 45} 46 47type PlaceholderItem = { 48 type: 'placeholder' 49 key: string 50} 51 52type ErrorItem = { 53 type: 'error' 54 key: string 55} 56 57type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem 58 59export function SearchablePeopleList({ 60 title, 61 showRecentConvos, 62 sortByMessageDeclaration, 63 onSelectChat, 64 renderProfileCard, 65}: { 66 title: string 67 showRecentConvos?: boolean 68 sortByMessageDeclaration?: boolean 69} & ( 70 | { 71 renderProfileCard: (item: ProfileItem) => React.ReactNode 72 onSelectChat?: undefined 73 } 74 | { 75 onSelectChat: (did: string) => void 76 renderProfileCard?: undefined 77 } 78)) { 79 const t = useTheme() 80 const {_} = useLingui() 81 const moderationOpts = useModerationOpts() 82 const control = Dialog.useDialogContext() 83 const [headerHeight, setHeaderHeight] = useState(0) 84 const listRef = useRef<ListMethods>(null) 85 const {currentAccount} = useSession() 86 const inputRef = useRef<TextInput>(null) 87 88 const [searchText, setSearchText] = useState('') 89 90 const enableSquareButtons = useEnableSquareButtons() 91 92 const { 93 data: results, 94 isError, 95 isFetching, 96 } = useActorAutocompleteQuery(searchText, true, 12) 97 const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 98 const {data: convos} = useListConvosQuery({ 99 enabled: showRecentConvos, 100 status: 'accepted', 101 }) 102 103 const items = useMemo(() => { 104 let _items: Item[] = [] 105 106 if (isError) { 107 _items.push({ 108 type: 'empty', 109 key: 'empty', 110 message: _(msg`We're having network issues, try again`), 111 }) 112 } else if (searchText.length) { 113 if (results?.length) { 114 for (const profile of results) { 115 if (profile.did === currentAccount?.did) continue 116 _items.push({ 117 type: 'profile', 118 key: profile.did, 119 profile, 120 }) 121 } 122 123 if (sortByMessageDeclaration) { 124 _items = _items.sort(item => { 125 return item.type === 'profile' && canBeMessaged(item.profile) 126 ? -1 127 : 1 128 }) 129 } 130 } 131 } else { 132 const placeholders: Item[] = Array(10) 133 .fill(0) 134 .map((__, i) => ({ 135 type: 'placeholder', 136 key: i + '', 137 })) 138 139 if (showRecentConvos) { 140 if (convos && follows) { 141 const usedDids = new Set() 142 143 for (const page of convos.pages) { 144 for (const convo of page.convos) { 145 const profiles = convo.members.filter( 146 m => m.did !== currentAccount?.did, 147 ) 148 149 for (const profile of profiles) { 150 if (usedDids.has(profile.did)) continue 151 152 usedDids.add(profile.did) 153 154 _items.push({ 155 type: 'profile', 156 key: profile.did, 157 profile, 158 }) 159 } 160 } 161 } 162 163 let followsItems: ProfileItem[] = [] 164 165 for (const page of follows.pages) { 166 for (const profile of page.follows) { 167 if (usedDids.has(profile.did)) continue 168 169 followsItems.push({ 170 type: 'profile', 171 key: profile.did, 172 profile, 173 }) 174 } 175 } 176 177 if (sortByMessageDeclaration) { 178 // only sort follows 179 followsItems = followsItems.sort(item => { 180 return canBeMessaged(item.profile) ? -1 : 1 181 }) 182 } 183 184 // then append 185 _items.push(...followsItems) 186 } else { 187 _items.push(...placeholders) 188 } 189 } else if (follows) { 190 for (const page of follows.pages) { 191 for (const profile of page.follows) { 192 _items.push({ 193 type: 'profile', 194 key: profile.did, 195 profile, 196 }) 197 } 198 } 199 200 if (sortByMessageDeclaration) { 201 _items = _items.sort(item => { 202 return item.type === 'profile' && canBeMessaged(item.profile) 203 ? -1 204 : 1 205 }) 206 } 207 } else { 208 _items.push(...placeholders) 209 } 210 } 211 212 return _items 213 }, [ 214 _, 215 searchText, 216 results, 217 isError, 218 currentAccount?.did, 219 follows, 220 convos, 221 showRecentConvos, 222 sortByMessageDeclaration, 223 ]) 224 225 if (searchText && !isFetching && !items.length && !isError) { 226 items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) 227 } 228 229 const renderItems = useCallback( 230 ({item}: {item: Item}) => { 231 switch (item.type) { 232 case 'profile': { 233 if (renderProfileCard) { 234 return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> 235 } else { 236 return ( 237 <DefaultProfileCard 238 key={item.key} 239 profile={item.profile} 240 moderationOpts={moderationOpts!} 241 onPress={onSelectChat} 242 /> 243 ) 244 } 245 } 246 case 'placeholder': { 247 return <ProfileCardSkeleton key={item.key} /> 248 } 249 case 'empty': { 250 return <Empty key={item.key} message={item.message} /> 251 } 252 default: 253 return null 254 } 255 }, 256 [moderationOpts, onSelectChat, renderProfileCard], 257 ) 258 259 useLayoutEffect(() => { 260 if (IS_WEB) { 261 setImmediate(() => { 262 inputRef?.current?.focus() 263 }) 264 } 265 }, []) 266 267 const listHeader = useMemo(() => { 268 return ( 269 <View 270 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 271 style={[ 272 a.relative, 273 web(a.pt_lg), 274 native(a.pt_4xl), 275 android({ 276 borderTopLeftRadius: a.rounded_md.borderRadius, 277 borderTopRightRadius: a.rounded_md.borderRadius, 278 }), 279 a.pb_xs, 280 a.px_lg, 281 a.border_b, 282 t.atoms.border_contrast_low, 283 t.atoms.bg, 284 ]}> 285 <View style={[a.relative, native(a.align_center), a.justify_center]}> 286 <Text 287 style={[ 288 a.z_10, 289 a.text_lg, 290 a.font_bold, 291 a.leading_tight, 292 t.atoms.text_contrast_high, 293 ]}> 294 {title} 295 </Text> 296 {IS_WEB ? ( 297 <Button 298 label={_(msg`Close`)} 299 size="small" 300 shape={enableSquareButtons ? 'square' : 'round'} 301 variant={IS_WEB ? 'ghost' : 'solid'} 302 color="secondary" 303 style={[ 304 a.absolute, 305 a.z_20, 306 web({right: -4}), 307 native({right: 0}), 308 native({height: 32, width: 32, borderRadius: 16}), 309 ]} 310 onPress={() => control.close()}> 311 <ButtonIcon icon={X} size="md" /> 312 </Button> 313 ) : null} 314 </View> 315 316 <View style={web([a.pt_xs])}> 317 <SearchInput 318 inputRef={inputRef} 319 value={searchText} 320 onChangeText={text => { 321 setSearchText(text) 322 listRef.current?.scrollToOffset({offset: 0, animated: false}) 323 }} 324 onEscape={control.close} 325 /> 326 </View> 327 </View> 328 ) 329 }, [ 330 t.atoms.border_contrast_low, 331 t.atoms.bg, 332 t.atoms.text_contrast_high, 333 _, 334 title, 335 searchText, 336 control, 337 enableSquareButtons, 338 ]) 339 340 return ( 341 <Dialog.InnerFlatList 342 ref={listRef} 343 data={items} 344 renderItem={renderItems} 345 ListHeaderComponent={listHeader} 346 stickyHeaderIndices={[0]} 347 keyExtractor={(item: Item) => item.key} 348 style={[ 349 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 350 native({height: '100%'}), 351 ]} 352 webInnerContentContainerStyle={a.py_0} 353 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 354 scrollIndicatorInsets={{top: headerHeight}} 355 keyboardDismissMode="on-drag" 356 /> 357 ) 358} 359 360function DefaultProfileCard({ 361 profile, 362 moderationOpts, 363 onPress, 364}: { 365 profile: bsky.profile.AnyProfileView 366 moderationOpts: ModerationOpts 367 onPress: (did: string) => void 368}) { 369 const t = useTheme() 370 const {_} = useLingui() 371 const enabled = canBeMessaged(profile) 372 const moderation = moderateProfile(profile, moderationOpts) 373 const handle = sanitizeHandle(profile.handle, '@') 374 const displayName = sanitizeDisplayName( 375 profile.displayName || sanitizeHandle(profile.handle), 376 moderation.ui('displayName'), 377 ) 378 379 const handleOnPress = useCallback(() => { 380 onPress(profile.did) 381 }, [onPress, profile.did]) 382 383 return ( 384 <Button 385 disabled={!enabled} 386 label={_(msg`Start chat with ${displayName}`)} 387 onPress={handleOnPress}> 388 {({hovered, pressed, focused}) => ( 389 <View 390 style={[ 391 a.flex_1, 392 a.py_sm, 393 a.px_lg, 394 !enabled 395 ? {opacity: 0.5} 396 : pressed || focused || hovered 397 ? t.atoms.bg_contrast_25 398 : t.atoms.bg, 399 ]}> 400 <ProfileCard.Header> 401 <ProfileCard.Avatar 402 profile={profile} 403 moderationOpts={moderationOpts} 404 disabledPreview 405 /> 406 <View style={[a.flex_1]}> 407 <ProfileCard.Name 408 profile={profile} 409 moderationOpts={moderationOpts} 410 /> 411 {enabled ? ( 412 <ProfileCard.Handle profile={profile} /> 413 ) : ( 414 <Text 415 style={[a.leading_snug, t.atoms.text_contrast_high]} 416 numberOfLines={2}> 417 <Trans>{handle} can't be messaged</Trans> 418 </Text> 419 )} 420 </View> 421 </ProfileCard.Header> 422 </View> 423 )} 424 </Button> 425 ) 426} 427 428function ProfileCardSkeleton() { 429 const t = useTheme() 430 const enableSquareButtons = useEnableSquareButtons() 431 432 return ( 433 <View 434 style={[ 435 a.flex_1, 436 a.py_md, 437 a.px_lg, 438 a.gap_md, 439 a.align_center, 440 a.flex_row, 441 ]}> 442 <View 443 style={[ 444 enableSquareButtons ? a.rounded_sm : a.rounded_full, 445 {width: 42, height: 42}, 446 t.atoms.bg_contrast_25, 447 ]} 448 /> 449 450 <View style={[a.flex_1, a.gap_sm]}> 451 <View 452 style={[ 453 a.rounded_xs, 454 {width: 80, height: 14}, 455 t.atoms.bg_contrast_25, 456 ]} 457 /> 458 <View 459 style={[ 460 a.rounded_xs, 461 {width: 120, height: 10}, 462 t.atoms.bg_contrast_25, 463 ]} 464 /> 465 </View> 466 </View> 467 ) 468} 469 470function Empty({message}: {message: string}) { 471 const t = useTheme() 472 return ( 473 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 474 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 475 {message} 476 </Text> 477 478 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> 479 </View> 480 ) 481} 482 483function SearchInput({ 484 value, 485 onChangeText, 486 onEscape, 487 inputRef, 488}: { 489 value: string 490 onChangeText: (text: string) => void 491 onEscape: () => void 492 inputRef: React.RefObject<TextInput | null> 493}) { 494 const t = useTheme() 495 const {_} = useLingui() 496 const { 497 state: hovered, 498 onIn: onMouseEnter, 499 onOut: onMouseLeave, 500 } = useInteractionState() 501 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 502 const interacted = hovered || focused 503 504 return ( 505 <View 506 {...web({ 507 onMouseEnter, 508 onMouseLeave, 509 })} 510 style={[a.flex_row, a.align_center, a.gap_sm]}> 511 <Search 512 size="md" 513 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 514 /> 515 516 <TextInput 517 // @ts-ignore bottom sheet input types issue — esb 518 ref={inputRef} 519 placeholder={_(msg`Search`)} 520 value={value} 521 onChangeText={onChangeText} 522 onFocus={onFocus} 523 onBlur={onBlur} 524 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 525 placeholderTextColor={t.palette.contrast_500} 526 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 527 returnKeyType="search" 528 clearButtonMode="while-editing" 529 maxLength={50} 530 onKeyPress={({nativeEvent}) => { 531 if (nativeEvent.key === 'Escape') { 532 onEscape() 533 } 534 }} 535 autoCorrect={false} 536 autoComplete="off" 537 autoCapitalize="none" 538 autoFocus 539 accessibilityLabel={_(msg`Search profiles`)} 540 accessibilityHint={_(msg`Searches for profiles`)} 541 /> 542 </View> 543 ) 544}