import {useCallback, useMemo, useRef, useState} from 'react' import {View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import * as SMS from 'expo-sms' import {type ModerationOpts} from '@atproto/api' import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useMutation, useQueryClient} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import { updateProfileShadow, useProfileShadow, } from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import { optimisticRemoveMatch, useMatchesPassthroughQuery, } from '#/state/queries/find-contacts' import {useAgent, useSession} from '#/state/session' import {List, type ListMethods} from '#/view/com/util/List' import {UserAvatar} from '#/view/com/util/UserAvatar' import {OnboardingPosition} from '#/screens/Onboarding/Layout' import {bulkWriteFollows} from '#/screens/Onboarding/util' import {atoms as a, tokens, useGutters, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {SearchInput} from '#/components/forms/SearchInput' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass' import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as Layout from '#/components/Layout' import {ListFooter} from '#/components/Lists' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' import {InviteInfo} from '../components/InviteInfo' import {type Action, type Contact, type Match, type State} from '../state' type Item = | { type: 'matches header' count: number } | { type: 'match' match: Match } | { type: 'contacts header' } | { type: 'contact' contact: Contact } | { type: 'no matches header' } | { type: 'search empty state' query: string } | { type: 'totally empty state' } export function ViewMatches({ state, dispatch, context, onNext, }: { state: Extract dispatch: React.ActionDispatch<[Action]> context: 'Onboarding' | 'Standalone' onNext: () => void }) { const t = useTheme() const {_} = useLingui() const ax = useAnalytics() const gutter = useGutters([0, 'wide']) const moderationOpts = useModerationOpts() const queryClient = useQueryClient() const agent = useAgent() const insets = useSafeAreaInsets() const listRef = useRef(null) const [search, setSearch] = useState('') const { state: searchFocused, onIn: onFocus, onOut: onBlur, } = useInteractionState() // HACK: Although we already have the match data, we need to pass it through // a query to get it into the shadow state const allMatches = useMatchesPassthroughQuery(state.matches) const matches = allMatches.filter( match => !state.dismissedMatches.includes(match.profile.did), ) const followableDids = matches.map(match => match.profile.did) const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) const cumulativeFollowCount = useRef(0) const onFollow = useCallback(() => { ax.metric('contacts:matches:follow', {entryPoint: context}) cumulativeFollowCount.current += 1 }, [ax, context]) const {mutate: followAll, isPending: isFollowingAll} = useMutation({ mutationFn: async () => { for (const did of followableDids) { updateProfileShadow(queryClient, did, { followingUri: 'pending', }) } const uris = await wait(500, bulkWriteFollows(agent, followableDids)) for (const did of followableDids) { const uri = uris.get(did) updateProfileShadow(queryClient, did, { followingUri: uri, }) } return followableDids }, onMutate: () => ax.metric('contacts:matches:followAll', { followCount: followableDids.length, entryPoint: context, }), onSuccess: () => { setDidFollowAll(true) Toast.show(_(msg`All friends followed!`), {type: 'success'}) cumulativeFollowCount.current += followableDids.length }, onError: _err => { Toast.show(_(msg`Failed to follow all your friends, please try again`), { type: 'error', }) for (const did of followableDids) { updateProfileShadow(queryClient, did, { followingUri: undefined, }) } }, }) const items = useMemo(() => { const all: Item[] = [] if (searchFocused || search.length > 0) { for (const match of matches) { if ( search.length === 0 || (match.profile.displayName ?? '') .toLocaleLowerCase() .includes(search.toLocaleLowerCase()) || match.profile.handle .toLocaleLowerCase() .includes(search.toLocaleLowerCase()) ) { all.push({type: 'match', match}) } } for (const contact of state.contacts) { if ( search.length === 0 || [contact.firstName, contact.lastName] .filter(Boolean) .join(' ') .toLocaleLowerCase() .includes(search.toLocaleLowerCase()) ) { all.push({type: 'contact', contact}) } } if (all.length === 0) { all.push({type: 'search empty state', query: search}) } } else { if (matches.length > 0) { all.push({type: 'matches header', count: matches.length}) for (const match of matches) { all.push({type: 'match', match}) } if (state.contacts.length > 0) { all.push({type: 'contacts header'}) } } else if (state.contacts.length > 0) { all.push({type: 'no matches header'}) } for (const contact of state.contacts) { all.push({type: 'contact', contact}) } if (all.length === 0) { all.push({type: 'totally empty state'}) } } return all }, [matches, state.contacts, search, searchFocused]) const {mutate: dismissMatch} = useMutation({ mutationFn: async (did: string) => { await agent.app.bsky.contact.dismissMatch({subject: did}) }, onMutate: did => { ax.metric('contacts:matches:dismiss', {entryPoint: context}) dispatch({type: 'DISMISS_MATCH', payload: {did}}) }, onSuccess: (_res, did) => { // for the other screen optimisticRemoveMatch(queryClient, did) }, onError: (err, did) => { dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}}) if (isNetworkError(err)) { Toast.show( _( msg`Failed to hide suggestion, please check your internet connection`, ), {type: 'error'}, ) } else { logger.error('Dismissing match failed', {safeMessage: err}) Toast.show( _(msg`An error occurred while hiding suggestion. ${cleanError(err)}`), {type: 'error'}, ) } }, }) const renderItem = ({item}: {item: Item}) => { switch (item.type) { case 'match': return ( ) case 'contact': return case 'matches header': return (
}> {item.count > 1 && ( )}
) case 'contacts header': return (
Invite friends{' '} } hasContentAbove /> ) case 'no matches header': return (
Bluesky is more fun with friends. Do you want to invite some of yours?{' '} } /> ) case 'search empty state': return case 'totally empty state': return } } const isSearchEmpty = items?.[0]?.type === 'search empty state' const isTotallyEmpty = items?.[0]?.type === 'totally empty state' const isEmpty = isSearchEmpty || isTotallyEmpty return ( {context === 'Standalone' && ( )} {!isTotallyEmpty && ( {context === 'Onboarding' && } { onFocus() listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onBlur={() => { onBlur() listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onChangeText={text => { setSearch(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onClearText={() => setSearch('')} /> )} : null} keyExtractor={keyExtractor} keyboardDismissMode="interactive" automaticallyAdjustKeyboardInsets /> ) } function keyExtractor(item: Item) { switch (item.type) { case 'contact': return item.contact.id case 'match': return item.match.profile.did default: return item.type } } function MatchItem({ profile, contact, moderationOpts, onRemoveSuggestion, onFollow, }: { profile: bsky.profile.AnyProfileView contact?: Contact moderationOpts?: ModerationOpts onRemoveSuggestion: (did: string) => void onFollow: () => void }) { const gutter = useGutters([0, 'wide']) const t = useTheme() const {_} = useLingui() const shadow = useProfileShadow(profile) const contactName = useMemo(() => { if (!contact) return null const name = contact.name ?? contact.firstName ?? contact.lastName if (name) return _(msg`Your contact ${name}`) const phone = contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] if (phone?.number) return phone.number return null }, [contact, _]) if (!moderationOpts) return null return ( {contactName && ( {contactName} )} {!shadow.viewer?.following && ( )} ) } function ContactItem({ contact, context, }: { contact: Contact context: 'Onboarding' | 'Standalone' }) { const gutter = useGutters([0, 'wide']) const t = useTheme() const {_} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const name = contact.name ?? contact.firstName ?? contact.lastName const phone = contact.phoneNumbers?.find(phone => phone.isPrimary) ?? contact.phoneNumbers?.[0] const phoneNumber = phone?.number return ( {contact.image ? ( ) : ( {name?.[0]?.toLocaleUpperCase()} )} {name ?? No name} {phoneNumber && currentAccount && ( )} ) } function Header({ titleText, largeTitle, subtitleText, children, hasContentAbove, }: { titleText: React.ReactNode largeTitle?: boolean subtitleText?: React.ReactNode children?: React.ReactNode hasContentAbove?: boolean }) { const gutter = useGutters([0, 'wide']) const t = useTheme() return ( {titleText} {children} {subtitleText && ( {subtitleText} )} ) } function SearchEmptyState({query}: {query: string}) { const t = useTheme() return ( No contacts with the name “{query}” found ) } function TotallyEmptyState() { const t = useTheme() return ( No contacts found ) }