forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {useCallback, useMemo, useRef, useState} from 'react'
2import {View} from 'react-native'
3import {useSafeAreaInsets} from 'react-native-safe-area-context'
4import * as SMS from 'expo-sms'
5import {type ModerationOpts} from '@atproto/api'
6import {msg, Plural, Trans} from '@lingui/macro'
7import {useLingui} from '@lingui/react'
8import {useMutation, useQueryClient} from '@tanstack/react-query'
9
10import {wait} from '#/lib/async/wait'
11import {cleanError, isNetworkError} from '#/lib/strings/errors'
12import {logger} from '#/logger'
13import {
14 updateProfileShadow,
15 useProfileShadow,
16} from '#/state/cache/profile-shadow'
17import {useModerationOpts} from '#/state/preferences/moderation-opts'
18import {
19 optimisticRemoveMatch,
20 useMatchesPassthroughQuery,
21} from '#/state/queries/find-contacts'
22import {useAgent, useSession} from '#/state/session'
23import {List, type ListMethods} from '#/view/com/util/List'
24import {UserAvatar} from '#/view/com/util/UserAvatar'
25import {OnboardingPosition} from '#/screens/Onboarding/Layout'
26import {bulkWriteFollows} from '#/screens/Onboarding/util'
27import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
28import {Button, ButtonIcon, ButtonText} from '#/components/Button'
29import {SearchInput} from '#/components/forms/SearchInput'
30import {useInteractionState} from '#/components/hooks/useInteractionState'
31import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
32import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass'
33import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person'
34import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
35import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
36import * as Layout from '#/components/Layout'
37import {ListFooter} from '#/components/Lists'
38import {Loader} from '#/components/Loader'
39import * as ProfileCard from '#/components/ProfileCard'
40import * as Toast from '#/components/Toast'
41import {Text} from '#/components/Typography'
42import type * as bsky from '#/types/bsky'
43import {InviteInfo} from '../components/InviteInfo'
44import {type Action, type Contact, type Match, type State} from '../state'
45
46type Item =
47 | {
48 type: 'matches header'
49 count: number
50 }
51 | {
52 type: 'match'
53 match: Match
54 }
55 | {
56 type: 'contacts header'
57 }
58 | {
59 type: 'contact'
60 contact: Contact
61 }
62 | {
63 type: 'no matches header'
64 }
65 | {
66 type: 'search empty state'
67 query: string
68 }
69 | {
70 type: 'totally empty state'
71 }
72
73export function ViewMatches({
74 state,
75 dispatch,
76 context,
77 onNext,
78}: {
79 state: Extract<State, {step: '4: view matches'}>
80 dispatch: React.ActionDispatch<[Action]>
81 context: 'Onboarding' | 'Standalone'
82 onNext: () => void
83}) {
84 const t = useTheme()
85 const {_} = useLingui()
86 const gutter = useGutters([0, 'wide'])
87 const moderationOpts = useModerationOpts()
88 const queryClient = useQueryClient()
89 const agent = useAgent()
90 const insets = useSafeAreaInsets()
91 const listRef = useRef<ListMethods>(null)
92
93 const [search, setSearch] = useState('')
94 const {
95 state: searchFocused,
96 onIn: onFocus,
97 onOut: onBlur,
98 } = useInteractionState()
99
100 // HACK: Although we already have the match data, we need to pass it through
101 // a query to get it into the shadow state
102 const allMatches = useMatchesPassthroughQuery(state.matches)
103 const matches = allMatches.filter(
104 match => !state.dismissedMatches.includes(match.profile.did),
105 )
106
107 const followableDids = matches.map(match => match.profile.did)
108 const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
109
110 const cumulativeFollowCount = useRef(0)
111 const onFollow = useCallback(() => {
112 logger.metric('contacts:matches:follow', {entryPoint: context})
113 cumulativeFollowCount.current += 1
114 }, [context])
115
116 const {mutate: followAll, isPending: isFollowingAll} = useMutation({
117 mutationFn: async () => {
118 for (const did of followableDids) {
119 updateProfileShadow(queryClient, did, {
120 followingUri: 'pending',
121 })
122 }
123
124 const uris = await wait(500, bulkWriteFollows(agent, followableDids))
125
126 for (const did of followableDids) {
127 const uri = uris.get(did)
128 updateProfileShadow(queryClient, did, {
129 followingUri: uri,
130 })
131 }
132 return followableDids
133 },
134 onMutate: () =>
135 logger.metric('contacts:matches:followAll', {
136 followCount: followableDids.length,
137 entryPoint: context,
138 }),
139 onSuccess: () => {
140 setDidFollowAll(true)
141 Toast.show(_(msg`All friends followed!`), {type: 'success'})
142 cumulativeFollowCount.current += followableDids.length
143 },
144 onError: _err => {
145 Toast.show(_(msg`Failed to follow all your friends, please try again`), {
146 type: 'error',
147 })
148 for (const did of followableDids) {
149 updateProfileShadow(queryClient, did, {
150 followingUri: undefined,
151 })
152 }
153 },
154 })
155
156 const items = useMemo(() => {
157 const all: Item[] = []
158
159 if (searchFocused || search.length > 0) {
160 for (const match of matches) {
161 if (
162 search.length === 0 ||
163 (match.profile.displayName ?? '')
164 .toLocaleLowerCase()
165 .includes(search.toLocaleLowerCase()) ||
166 match.profile.handle
167 .toLocaleLowerCase()
168 .includes(search.toLocaleLowerCase())
169 ) {
170 all.push({type: 'match', match})
171 }
172 }
173
174 for (const contact of state.contacts) {
175 if (
176 search.length === 0 ||
177 [contact.firstName, contact.lastName]
178 .filter(Boolean)
179 .join(' ')
180 .toLocaleLowerCase()
181 .includes(search.toLocaleLowerCase())
182 ) {
183 all.push({type: 'contact', contact})
184 }
185 }
186
187 if (all.length === 0) {
188 all.push({type: 'search empty state', query: search})
189 }
190 } else {
191 if (matches.length > 0) {
192 all.push({type: 'matches header', count: matches.length})
193 for (const match of matches) {
194 all.push({type: 'match', match})
195 }
196
197 if (state.contacts.length > 0) {
198 all.push({type: 'contacts header'})
199 }
200 } else if (state.contacts.length > 0) {
201 all.push({type: 'no matches header'})
202 }
203
204 for (const contact of state.contacts) {
205 all.push({type: 'contact', contact})
206 }
207
208 if (all.length === 0) {
209 all.push({type: 'totally empty state'})
210 }
211 }
212
213 return all
214 }, [matches, state.contacts, search, searchFocused])
215
216 const {mutate: dismissMatch} = useMutation({
217 mutationFn: async (did: string) => {
218 await agent.app.bsky.contact.dismissMatch({subject: did})
219 },
220 onMutate: did => {
221 logger.metric('contacts:matches:dismiss', {entryPoint: context})
222 dispatch({type: 'DISMISS_MATCH', payload: {did}})
223 },
224 onSuccess: (_res, did) => {
225 // for the other screen
226 optimisticRemoveMatch(queryClient, did)
227 },
228 onError: (err, did) => {
229 dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}})
230 if (isNetworkError(err)) {
231 Toast.show(
232 _(
233 msg`Failed to hide suggestion, please check your internet connection`,
234 ),
235 {type: 'error'},
236 )
237 } else {
238 logger.error('Dismissing match failed', {safeMessage: err})
239 Toast.show(
240 _(msg`An error occurred while hiding suggestion. ${cleanError(err)}`),
241 {type: 'error'},
242 )
243 }
244 },
245 })
246
247 const renderItem = ({item}: {item: Item}) => {
248 switch (item.type) {
249 case 'match':
250 return (
251 <MatchItem
252 profile={item.match.profile}
253 contact={item.match.contact}
254 moderationOpts={moderationOpts}
255 onRemoveSuggestion={dismissMatch}
256 onFollow={onFollow}
257 />
258 )
259 case 'contact':
260 return <ContactItem contact={item.contact} context={context} />
261 case 'matches header':
262 return (
263 <Header
264 titleText={
265 <Plural
266 value={item.count}
267 one="# friend found!"
268 other="# friends found!"
269 />
270 }>
271 {item.count > 1 && (
272 <Button
273 label={_(msg`Follow all`)}
274 size="small"
275 color="primary_subtle"
276 onPress={() => followAll()}
277 disabled={isFollowingAll || didFollowAll}>
278 <ButtonIcon
279 icon={
280 isFollowingAll
281 ? Loader
282 : !didFollowAll
283 ? PlusIcon
284 : CheckIcon
285 }
286 />
287 <ButtonText>
288 <Trans>Follow all</Trans>
289 </ButtonText>
290 </Button>
291 )}
292 </Header>
293 )
294 case 'contacts header':
295 return (
296 <Header
297 titleText={
298 <Trans>
299 Invite friends{' '}
300 <InviteInfo iconStyle={t.atoms.text} iconOffset={1} />
301 </Trans>
302 }
303 hasContentAbove
304 />
305 )
306 case 'no matches header':
307 return (
308 <Header
309 titleText={_(msg`You got here first`)}
310 largeTitle
311 subtitleText={
312 <Trans>
313 Bluesky is more fun with friends. Do you want to invite some of
314 yours?{' '}
315 <InviteInfo
316 iconStyle={t.atoms.text_contrast_medium}
317 iconOffset={2}
318 />
319 </Trans>
320 }
321 />
322 )
323 case 'search empty state':
324 return <SearchEmptyState query={item.query} />
325 case 'totally empty state':
326 return <TotallyEmptyState />
327 }
328 }
329
330 const isSearchEmpty = items?.[0]?.type === 'search empty state'
331 const isTotallyEmpty = items?.[0]?.type === 'totally empty state'
332
333 const isEmpty = isSearchEmpty || isTotallyEmpty
334
335 return (
336 <View style={[a.h_full]}>
337 {context === 'Standalone' && (
338 <Layout.Header.Outer noBottomBorder>
339 <Layout.Header.BackButton />
340 <Layout.Header.Content />
341 <Layout.Header.Slot />
342 </Layout.Header.Outer>
343 )}
344 {!isTotallyEmpty && (
345 <View
346 style={[
347 gutter,
348 a.mb_md,
349 context === 'Onboarding' && [a.mt_sm, a.gap_sm],
350 ]}>
351 {context === 'Onboarding' && <OnboardingPosition />}
352 <SearchInput
353 placeholder={_(msg`Search contacts`)}
354 value={search}
355 onFocus={() => {
356 onFocus()
357 listRef.current?.scrollToOffset({offset: 0, animated: false})
358 }}
359 onBlur={() => {
360 onBlur()
361 listRef.current?.scrollToOffset({offset: 0, animated: false})
362 }}
363 onChangeText={text => {
364 setSearch(text)
365 listRef.current?.scrollToOffset({offset: 0, animated: false})
366 }}
367 onClearText={() => setSearch('')}
368 />
369 </View>
370 )}
371 <List
372 ref={listRef}
373 data={items}
374 renderItem={renderItem}
375 ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null}
376 keyExtractor={keyExtractor}
377 keyboardDismissMode="interactive"
378 automaticallyAdjustKeyboardInsets
379 />
380 <View
381 style={[
382 t.atoms.bg,
383 t.atoms.border_contrast_low,
384 a.border_t,
385 a.align_center,
386 a.align_stretch,
387 gutter,
388 a.pt_md,
389 {paddingBottom: insets.bottom + tokens.space.md},
390 ]}>
391 <Button
392 label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
393 onPress={() => {
394 if (context === 'Onboarding') {
395 logger.metric('onboarding:contacts:nextPressed', {
396 matchCount: allMatches.length,
397 followCount: cumulativeFollowCount.current,
398 dismissedMatchCount: state.dismissedMatches.length,
399 })
400 }
401 onNext()
402 }}
403 size="large"
404 color="primary">
405 <ButtonText>
406 {context === 'Onboarding' ? (
407 <Trans>Next</Trans>
408 ) : (
409 <Trans>Done</Trans>
410 )}
411 </ButtonText>
412 </Button>
413 </View>
414 </View>
415 )
416}
417
418function keyExtractor(item: Item) {
419 switch (item.type) {
420 case 'contact':
421 return item.contact.id
422 case 'match':
423 return item.match.profile.did
424 default:
425 return item.type
426 }
427}
428
429function MatchItem({
430 profile,
431 contact,
432 moderationOpts,
433 onRemoveSuggestion,
434 onFollow,
435}: {
436 profile: bsky.profile.AnyProfileView
437 contact?: Contact
438 moderationOpts?: ModerationOpts
439 onRemoveSuggestion: (did: string) => void
440 onFollow: () => void
441}) {
442 const gutter = useGutters([0, 'wide'])
443 const t = useTheme()
444 const {_} = useLingui()
445 const shadow = useProfileShadow(profile)
446
447 const contactName = useMemo(() => {
448 if (!contact) return null
449
450 const name = contact.name ?? contact.firstName ?? contact.lastName
451 if (name) return _(msg`Your contact ${name}`)
452 const phone =
453 contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0]
454 if (phone?.number) return phone.number
455 return null
456 }, [contact, _])
457
458 if (!moderationOpts) return null
459
460 return (
461 <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
462 <ProfileCard.Header>
463 <ProfileCard.Avatar
464 profile={profile}
465 moderationOpts={moderationOpts}
466 size={48}
467 />
468 <View style={[a.flex_1]}>
469 <ProfileCard.Name
470 profile={profile}
471 moderationOpts={moderationOpts}
472 textStyle={[a.leading_tight]}
473 />
474 <ProfileCard.Handle
475 profile={profile}
476 textStyle={[contactName && a.text_xs]}
477 />
478 {contactName && (
479 <Text
480 emoji
481 style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]}
482 numberOfLines={1}>
483 {contactName}
484 </Text>
485 )}
486 </View>
487 <ProfileCard.FollowButton
488 profile={profile}
489 moderationOpts={moderationOpts}
490 logContext="FindContacts"
491 onFollow={onFollow}
492 />
493 {!shadow.viewer?.following && (
494 <Button
495 color="secondary"
496 variant="ghost"
497 label={_(msg`Remove suggestion`)}
498 onPress={() => onRemoveSuggestion(profile.did)}
499 hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
500 hitSlop={8}>
501 <ButtonIcon icon={XIcon} />
502 </Button>
503 )}
504 </ProfileCard.Header>
505 </View>
506 )
507}
508
509function ContactItem({
510 contact,
511 context,
512}: {
513 contact: Contact
514 context: 'Onboarding' | 'Standalone'
515}) {
516 const gutter = useGutters([0, 'wide'])
517 const t = useTheme()
518 const {_} = useLingui()
519 const {currentAccount} = useSession()
520
521 const name = contact.name ?? contact.firstName ?? contact.lastName
522 const phone =
523 contact.phoneNumbers?.find(phone => phone.isPrimary) ??
524 contact.phoneNumbers?.[0]
525 const phoneNumber = phone?.number
526
527 return (
528 <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
529 <ProfileCard.Header>
530 {contact.image ? (
531 <UserAvatar size={40} avatar={contact.image.uri} type="user" />
532 ) : (
533 <View
534 style={[
535 {width: 40, height: 40},
536 a.rounded_full,
537 a.justify_center,
538 a.align_center,
539 t.atoms.bg_contrast_400,
540 ]}>
541 <Text
542 style={[
543 a.text_lg,
544 a.font_semi_bold,
545 {color: t.palette.contrast_0},
546 ]}>
547 {name?.[0]?.toLocaleUpperCase()}
548 </Text>
549 </View>
550 )}
551 <Text
552 style={[
553 a.flex_1,
554 a.text_md,
555 a.font_medium,
556 !name && [t.atoms.text_contrast_medium, a.italic],
557 ]}
558 numberOfLines={2}>
559 {name ?? <Trans>No name</Trans>}
560 </Text>
561 {phoneNumber && currentAccount && (
562 <Button
563 label={_(msg`Invite ${name} to join Bluesky`)}
564 color="secondary"
565 size="small"
566 onPress={async () => {
567 logger.metric('contacts:matches:invite', {
568 entryPoint: context,
569 })
570 try {
571 await SMS.sendSMSAsync(
572 [phoneNumber],
573 _(
574 msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`,
575 ),
576 )
577 } catch (err) {
578 Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'})
579 logger.error('Could not launch SMS', {safeMessage: err})
580 }
581 }}>
582 <ButtonText>
583 <Trans>Invite</Trans>
584 </ButtonText>
585 </Button>
586 )}
587 </ProfileCard.Header>
588 </View>
589 )
590}
591
592function Header({
593 titleText,
594 largeTitle,
595 subtitleText,
596 children,
597 hasContentAbove,
598}: {
599 titleText: React.ReactNode
600 largeTitle?: boolean
601 subtitleText?: React.ReactNode
602 children?: React.ReactNode
603 hasContentAbove?: boolean
604}) {
605 const gutter = useGutters([0, 'wide'])
606 const t = useTheme()
607
608 return (
609 <View
610 style={[
611 gutter,
612 a.pb_md,
613 a.gap_sm,
614 hasContentAbove
615 ? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low]
616 : a.pt_md,
617 ]}>
618 <View style={[a.flex_row, a.align_center, a.justify_between]}>
619 <Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}>
620 {titleText}
621 </Text>
622 {children}
623 </View>
624 {subtitleText && (
625 <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
626 {subtitleText}
627 </Text>
628 )}
629 </View>
630 )
631}
632
633function SearchEmptyState({query}: {query: string}) {
634 const t = useTheme()
635
636 return (
637 <View
638 style={[
639 a.flex_1,
640 a.flex_col,
641 a.align_center,
642 a.justify_center,
643 a.gap_lg,
644 a.pt_5xl,
645 a.px_5xl,
646 ]}>
647 <SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} />
648 <Text
649 style={[
650 a.text_md,
651 a.leading_snug,
652 t.atoms.text_contrast_medium,
653 a.text_center,
654 ]}>
655 <Trans>No contacts with the name “{query}” found</Trans>
656 </Text>
657 </View>
658 )
659}
660
661function TotallyEmptyState() {
662 const t = useTheme()
663
664 return (
665 <View
666 style={[
667 a.flex_1,
668 a.flex_col,
669 a.align_center,
670 a.justify_center,
671 a.gap_lg,
672 {paddingTop: 140},
673 a.px_5xl,
674 ]}>
675 <PersonXIcon width={64} style={[t.atoms.text_contrast_low]} />
676 <Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}>
677 <Trans>No contacts found</Trans>
678 </Text>
679 </View>
680 )
681}