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