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