[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
+107 -95
View File
@@ -1,13 +1,12 @@
import React, {useState} from 'react'
import {useEffect, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native'
import {Dimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isAndroid, isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {
atoms as a,
native,
@@ -22,6 +21,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
import {HEADER_SLOT_SIZE} from '#/components/Layout'
import {createPortalGroup} from '#/components/Portal'
import {P, Text} from '#/components/Typography'
import {IS_INTERNAL} from '#/env'
const ONBOARDING_COL_WIDTH = 420
@@ -34,20 +34,20 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardDispatch = useOnboardingDispatch()
const {state, dispatch} = React.useContext(Context)
const scrollview = React.useRef<ScrollView>(null)
const prevActiveStep = React.useRef<string>(state.activeStep)
const {state, dispatch} = useOnboardingInternalState()
const scrollview = useRef<ScrollView>(null)
const prevActiveStep = useRef<string>(state.activeStep)
React.useEffect(() => {
useEffect(() => {
if (state.activeStep !== prevActiveStep.current) {
prevActiveStep.current = state.activeStep
scrollview.current?.scrollTo({y: 0, animated: false})
}
}, [state])
const paddingTop = gtMobile ? a.py_5xl : a.py_lg
const dialogLabel = _(msg`Set up your account`)
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
return (
@@ -59,33 +59,12 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
accessibilityLabel={dialogLabel}
accessibilityHint={_(msg`Customizes your Bluesky experience`)}
style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
{__DEV__ && (
<Button
variant="ghost"
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state"
style={[
a.absolute,
a.z_10,
{
left: isWeb ? '50%' : Dimensions.get('window').width / 2 - 45,
top: insets.top + 2,
},
web({transform: [{translateX: '-50%'}]}),
]}>
<ButtonText>[DEV] Clear</ButtonText>
</Button>
)}
{!gtMobile && (
{!gtMobile ? (
<View
pointerEvents="box-none"
style={[
web(a.fixed),
native(a.absolute),
a.top_0,
a.left_0,
a.right_0,
a.flex_row,
@@ -93,85 +72,94 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.justify_center,
a.z_20,
a.px_xl,
{top: paddingTop.paddingTop + insets.top - 1},
]}>
{paddingTop: (web(tokens.space.lg) ?? 0) + insets.top},
native([t.atoms.bg, a.pb_xs, {minHeight: 48}]),
web(a.pointer_events_box_none),
]}
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View
pointerEvents="box-none"
style={[
a.w_full,
a.align_start,
a.align_center,
a.flex_row,
a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH},
web({maxWidth: ONBOARDING_COL_WIDTH}),
web(a.pointer_events_box_none),
]}>
{state.hasPrev ? (
<HeaderSlot>
{state.canGoBack && (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
variant="ghost"
shape="round"
size="small"
label={_(msg`Go back to previous step`)}
onPress={() => dispatch({type: 'prev'})}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
)}
</HeaderSlot>
{IS_INTERNAL && (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
variant="ghost"
shape="square"
size="small"
label={_(msg`Go back to previous step`)}
onPress={() => dispatch({type: 'prev'})}
style={[a.bg_transparent]}>
<ButtonIcon icon={ArrowLeft} size="lg" />
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
<ButtonText>[DEV] Clear</ButtonText>
</Button>
) : (
<View />
)}
<OnboardingHeaderSlot.Outlet />
<HeaderSlot>
<OnboardingHeaderSlot.Outlet />
</HeaderSlot>
</View>
</View>
) : (
<>
{IS_INTERNAL && (
<View
style={[
a.absolute,
a.align_center,
a.z_10,
{top: 0, left: 0, right: 0},
]}>
<Button
variant="ghost"
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
<ButtonText>[DEV] Clear</ButtonText>
</Button>
</View>
)}
</>
)}
<ScrollView
ref={scrollview}
style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
contentContainerStyle={{borderWidth: 0, minHeight: '100%'}}
style={[a.h_full, a.w_full]}
contentContainerStyle={{
borderWidth: 0,
minHeight: '100%',
paddingTop: gtMobile ? 40 : headerHeight,
paddingBottom: footerHeight,
}}
showsVerticalScrollIndicator={!isAndroid}
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
// @ts-expect-error web only --prf
dataSet={{'stable-gutters': 1}}>
dataSet={{'stable-gutters': 1}}
centerContent={gtMobile}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
<View style={[a.w_full, a.align_center, paddingTop]}>
<View
style={[
a.flex_row,
a.gap_sm,
a.w_full,
a.align_center,
{height: HEADER_SLOT_SIZE, maxWidth: '60%'},
]}>
{Array(state.totalSteps)
.fill(0)
.map((__, i) => (
<View
key={i}
style={[
a.flex_1,
a.pt_xs,
a.rounded_full,
t.atoms.bg_contrast_50,
{
backgroundColor:
i + 1 <= state.activeStepIndex
? t.palette.primary_500
: t.palette.contrast_100,
},
]}
/>
))}
</View>
</View>
<View style={[a.w_full, a.h_full, a.mb_5xl, a.pt_md]}>
{children}
</View>
<View style={{height: 100 + footerHeight}} />
<View style={[a.flex_1, web({maxWidth: ONBOARDING_COL_WIDTH})]}>
<View style={[a.w_full, a.py_md]}>{children}</View>
</View>
</View>
</ScrollView>
@@ -200,7 +188,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
gtMobile && [a.flex_row, a.justify_between, a.align_center],
]}>
{gtMobile &&
(state.hasPrev ? (
(state.canGoBack ? (
<Button
key={state.activeStep} // remove focus state on nav
color="secondary"
@@ -221,22 +209,46 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
)
}
export function TitleText({
function HeaderSlot({children}: {children?: React.ReactNode}) {
return (
<View style={[{minHeight: HEADER_SLOT_SIZE, minWidth: HEADER_SLOT_SIZE}]}>
{children}
</View>
)
}
export function OnboardingPosition() {
const {state} = useOnboardingInternalState()
const t = useTheme()
return (
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>
Step {state.activeStepIndex + 1} of {state.totalSteps}
</Trans>
</Text>
)
}
export function OnboardingTitleText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
return (
<Text
style={[a.pb_sm, a.text_4xl, a.font_semi_bold, a.leading_tight, style]}>
<Text style={[a.text_3xl, a.font_bold, a.leading_snug, style]}>
{children}
</Text>
)
}
export function DescriptionText({
export function OnboardingDescriptionText({
children,
style,
}: React.PropsWithChildren<TextStyleProp>) {
const t = useTheme()
return <P style={[t.atoms.text_contrast_medium, style]}>{children}</P>
return (
<P style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium, style]}>
{children}
</P>
)
}