[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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user