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