diff --git a/src/components/contacts/screens/ViewMatches.tsx b/src/components/contacts/screens/ViewMatches.tsx index 3ced568129..8f40b2838e 100644 --- a/src/components/contacts/screens/ViewMatches.tsx +++ b/src/components/contacts/screens/ViewMatches.tsx @@ -1,13 +1,136 @@ +import {useMemo, useState} from 'react' import {View} from 'react-native' +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 {atoms as a} from '#/alf' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useSession} from '#/state/session' +import {List} from '#/view/com/util/List' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {SearchInput} from '#/components/forms/SearchInput' +import {Envelope_Stroke2_Corner0_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' -import {type Action, type State} from '../state' +import {ListFooter} from '#/components/Lists' +import * as ProfileCard from '#/components/ProfileCard' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {type Action, type Contact, type State} from '../state' -export function ViewMatches({}: { +type Item = + | { + type: 'matches header' + } + | { + type: 'match' + profile: bsky.profile.AnyProfileView + } + | { + type: 'contacts header' + } + | { + type: 'contact' + contact: Contact + } + | { + type: 'no matches header' + } + | { + type: 'search empty state' + } +// we could use Contacts.presentAccessPickerAsync() if we get limited permissions? +// | { +// type: 'request more contacts' +// } + +export function ViewMatches({ + state, +}: { state: Extract dispatch: React.Dispatch }) { + const {_} = useLingui() + const gutter = useGutters([0, 'wide']) + const moderationOpts = useModerationOpts() + + const [search, setSearch] = useState('') + + const items = useMemo(() => { + const all: Item[] = [] + + if (state.matches.length > 0) { + all.push({type: 'matches header'}) + for (const match of state.matches) { + const profile = match.profile + all.push({type: 'match', profile}) + } + all.push({type: 'contacts header'}) + } else { + all.push({type: 'no matches header'}) + } + + for (const contact of state.contacts) { + all.push({type: 'contact', contact}) + } + + return all + }, [state.matches, state.contacts]) + + const numMatches = state.matches.length + + const renderItem = ({item}: {item: Item}) => { + switch (item.type) { + case 'match': + return ( + + ) + case 'contact': + return + case 'matches header': + return ( +
+ }> + {numMatches > 1 && ( + + )} +
+ ) + case 'contacts header': + return
+ case 'no matches header': + return ( +
+ ) + case 'search empty state': + return null + } + } + return ( @@ -15,6 +138,160 @@ export function ViewMatches({}: { + + + + } + /> + + ) +} + +function MatchItem({ + profile, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts?: ModerationOpts +}) { + const gutter = useGutters([0, 'wide']) + const t = useTheme() + + if (!moderationOpts) return null + + return ( + + + + + + + + ) +} + +function ContactItem({contact}: {contact: Contact}) { + const gutter = useGutters([0, 'wide']) + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + + const 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, + subtitleText, + children, + hasContentAbove, +}: { + titleText: React.ReactNode + subtitleText?: React.ReactNode + children?: React.ReactNode + hasContentAbove?: boolean +}) { + const gutter = useGutters([0, 'wide']) + const t = useTheme() + + return ( + + + {titleText} + {children} + + {subtitleText && ( + + {subtitleText} + + )} ) } diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts index 2e57975690..4c0d843b1a 100644 --- a/src/components/contacts/state.ts +++ b/src/components/contacts/state.ts @@ -7,7 +7,7 @@ export type Contact = ExistingContact // TODO: replace with lexicon type export type Match = { - index: number + index?: number profile: bsky.profile.AnyProfileView } @@ -66,6 +66,12 @@ export type Action = | { type: 'BACK' } + | { + type: 'DISMISS_MATCH' + payload: { + did: string + } + } function reducer(state: State, action: Action): State { switch (action.type) { @@ -114,6 +120,15 @@ function reducer(state: State, action: Action): State { matches: action.payload.matches, } } + case 'DISMISS_MATCH': { + assertCurrentStep(state, '4: view matches') + return { + ...state, + matches: state.matches.filter( + match => match.profile.did !== action.payload.did, + ), + } + } } } diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index 8243b04028..0f798c6b39 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -309,6 +309,7 @@ export type MetricEvents = { | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' | 'OnboardingSuggestedAccounts' + | 'SyncContacts' } 'profile:followers:view': { contextProfileDid: string @@ -396,6 +397,7 @@ export type MetricEvents = { | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' | 'OnboardingSuggestedAccounts' + | 'SyncContacts' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'