mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

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