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 {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}