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