mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}