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