[Contacts] Contacts matching flow (#9486)

* add expo-contacts

* add expo-sms

* update copy

* add basic settings screen

* state machine, flow screen

* phone input screen

* otp screen

* tweak spacing

* resend code logic

* add layoutanimationconfig

* check availablility in settings screen

* get contacts

* matches screen

* search, temp setup for matches UI

* add a bunch of number parsing logic with libphonenumber

* cast to looser type

* rename sync to find

FCF (Find Contacts Flow)

* update geolocation hook

* nicer design for settings screen

* add completed state

* up border contrast

* update expo deps

* add pending spinner

* add country whitelist

* add empty state screen

* drop add more functionality

* upload -> import

* fix typo

* fix permission string

* copy updates

* rm envelope icon

* update sms copy

* add inviteinfo component

* woke is back

* [Contacts] NUXes (#9515)

* add a bunch of number parsing logic with libphonenumber

* add banner nux

* add announcement nux

* native only nux

* rm shitty animation

* move isNative check

* [Contacts] Onboarding step (#9489)

* add a bunch of number parsing logic with libphonenumber

* restructure onboarding to better support dynamic screens

* integrate existing flow into onboarding

* add intro step

* lift state up to allow going back freely

* gate onboarding by geo if unsupported country

* add done button to standalone flow

* [Contacts] Add `contact-match` notification type to feed (#9519)

* add a bunch of number parsing logic with libphonenumber

* add contact-joined notif type

* update string, api package

* Update NotificationFeedItem.tsx

* Update NotificationFeedItem.tsx

* Delete SyncContactsFlow.web.tsx

* fix follow back btn for this case

* [Contacts] API integration (#9487)

* api integration for flow

* copy tweak

* tweaks after running it

* wire up status page

* rename toast

* use api lib

* rm temp code

* maybe fix otp error

* clear code on error/resend

* add 1s delay to verify success

* update package versions

* Delete SyncContactsFlow.web.tsx

* try and fix yarn.lock lint

* even woker

* fix uppercase friends

* surfdude feedback

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* rm log

* allow resend on invalid code

* Update GetContacts.tsx

* overwrite country code if possible

* fix Apple's Best Feature

* devenv latest

* disable bounces

* interactive keyboard dismiss

* copy changes

* national format

* allow resending immediately

* move success state down a bit

* Update FindContactsSettings.tsx

* [Contacts] More onboarding changes (#9491)

* integrate existing flow into onboarding

* refreshed onboarding styles, rm stepper

* center content on web

* Add back dismiss button for internal onboarding

* Import sort

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* add matches query to shadow

* add clockwise arrow

* update status design, fix dismiss, fix queries

* add metrics

* add more comments

* Update metrics.ts

* refetch both queries on PTR

* fix shadow state in matches page

* reduce empty space at bottom

* show contact info on matches

* filter out contacts without numbers at an earlier stage

* Error handling 

* get notifs working

* filter out businesses

* rm log

* remove TODO from learn more links

* try and exclude from web bundle

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2025-12-12 20:32:06 +02:00
committed by GitHub
parent d3a6db4594
commit ad7d49ef8f
98 changed files with 4906 additions and 1079 deletions
+198
View File
@@ -0,0 +1,198 @@
import {createContext, useContext, useReducer} from 'react'
import {type GestureResponderEvent} from 'react-native'
import {type ExistingContact} from 'expo-contacts'
import {type CountryCode} from '#/lib/international-telephone-codes'
import type * as bsky from '#/types/bsky'
export type Contact = ExistingContact
export type Match = {
profile: bsky.profile.AnyProfileView
contact?: Contact
}
export type State =
| {
step: '1: phone input'
phoneCountryCode?: CountryCode
phoneNumber?: string
}
| {
step: '2: verify number'
phoneCountryCode: CountryCode
phoneNumber: string
lastSentAt: Date | null
}
| {
step: '3: get contacts'
phoneCountryCode: CountryCode
phoneNumber: string
token: string
contacts?: Contact[]
}
| {
step: '4: view matches'
contacts: Contact[]
matches: Match[]
// rather than mutating `matches`, we keep track of dismissed matches
// so we can roll back optimistic updates
dismissedMatches: string[]
}
export type Action =
| {
type: 'SUBMIT_PHONE_NUMBER'
payload: {
phoneCountryCode: CountryCode
phoneNumber: string
}
}
| {
type: 'RESEND_VERIFICATION_CODE'
}
| {
type: 'VERIFY_PHONE_NUMBER_SUCCESS'
payload: {
token: string
}
}
| {
type: 'GET_CONTACTS_SUCCESS'
payload: {
contacts: Contact[]
}
}
| {
type: 'SYNC_CONTACTS_SUCCESS'
payload: {
matches: Match[]
// filter out matched contacts
contacts: Contact[]
}
}
| {
type: 'BACK'
}
| {
type: 'DISMISS_MATCH'
payload: {
did: string
}
}
| {
type: 'DISMISS_MATCH_FAILED'
payload: {
did: string
}
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SUBMIT_PHONE_NUMBER': {
assertCurrentStep(state, '1: phone input')
return {
step: '2: verify number',
...action.payload,
lastSentAt: null,
}
}
case 'RESEND_VERIFICATION_CODE': {
assertCurrentStep(state, '2: verify number')
return {
...state,
lastSentAt: new Date(),
}
}
case 'VERIFY_PHONE_NUMBER_SUCCESS': {
assertCurrentStep(state, '2: verify number')
return {
step: '3: get contacts',
token: action.payload.token,
phoneCountryCode: state.phoneCountryCode,
phoneNumber: state.phoneNumber,
}
}
case 'BACK': {
assertCurrentStep(state, '2: verify number')
return {
step: '1: phone input',
phoneNumber: state.phoneNumber,
phoneCountryCode: state.phoneCountryCode,
}
}
case 'GET_CONTACTS_SUCCESS': {
assertCurrentStep(state, '3: get contacts')
return {
...state,
contacts: action.payload.contacts,
}
}
case 'SYNC_CONTACTS_SUCCESS': {
assertCurrentStep(state, '3: get contacts')
return {
step: '4: view matches',
contacts: action.payload.contacts,
matches: action.payload.matches,
dismissedMatches: [],
}
}
case 'DISMISS_MATCH': {
assertCurrentStep(state, '4: view matches')
return {
...state,
dismissedMatches: [
...new Set(state.dismissedMatches),
action.payload.did,
],
}
}
case 'DISMISS_MATCH_FAILED': {
assertCurrentStep(state, '4: view matches')
return {
...state,
dismissedMatches: state.dismissedMatches.filter(
did => did !== action.payload.did,
),
}
}
}
}
class InvalidStateTransitionError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidStateTransitionError'
}
}
function assertCurrentStep<S extends State['step']>(
state: State,
step: S,
): asserts state is Extract<State, {step: S}> {
if (state.step !== step) {
throw new InvalidStateTransitionError(
`Invalid state transition: expecting ${step}, got ${state.step}`,
)
}
}
export function useFindContactsFlowState(
initialState: State = {step: '1: phone input'},
) {
return useReducer(reducer, initialState)
}
export const FindContactsGoBackContext = createContext<
(() => void) | undefined
>(undefined)
export function useOnPressBackButton() {
const goBack = useContext(FindContactsGoBackContext)
if (!goBack) {
return undefined
}
return (evt: GestureResponderEvent) => {
evt.preventDefault()
goBack()
}
}