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