[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
+128
View File
@@ -0,0 +1,128 @@
import {type AppBskyContactGetMatches} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
import {type Match} from '#/components/contacts/state'
import type * as bsky from '#/types/bsky'
import {STALE} from '.'
const RQ_KEY_ROOT = 'find-contacts'
export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status']
export function useContactsSyncStatusQuery() {
const agent = useAgent()
return useQuery({
queryKey: findContactsStatusQueryKey,
queryFn: async () => {
const status = await agent.app.bsky.contact.getSyncStatus()
return status.data
},
staleTime: STALE.SECONDS.THIRTY,
})
}
export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches']
export function useContactsMatchesQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: findContactsGetMatchesQueryKey,
queryFn: async ({pageParam}) => {
const matches = await agent.app.bsky.contact.getMatches({
cursor: pageParam,
})
return matches.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.cursor,
staleTime: STALE.MINUTES.ONE,
})
}
export function optimisticRemoveMatch(queryClient: QueryClient, did: string) {
queryClient.setQueryData<InfiniteData<AppBskyContactGetMatches.OutputSchema>>(
findContactsGetMatchesQueryKey,
old => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
matches: page.matches.filter(match => match.did !== did),
})),
}
},
)
}
export const findContactsMatchesPassthroughQueryKey = (dids: string[]) => [
RQ_KEY_ROOT,
'passthrough',
dids,
]
/**
* DIRTY HACK WARNING!
*
* The only way to get shadow state to work is to put it into React Query.
* However, when we get the matches it's via a POST, not a GET, so we use a mutation,
* which means we can't use shadowing!
*
* In lieu of any better ideas, I'm just going to take the contacts we have and
* "launder" them through a dummy query. This will then return "shadow-able" profiles.
*/
export function useMatchesPassthroughQuery(matches: Match[]) {
const dids = matches.map(match => match.profile.did)
const {data} = useQuery({
queryKey: findContactsMatchesPassthroughQueryKey(dids),
queryFn: () => {
return matches
},
})
return data ?? matches
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<bsky.profile.AnyProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyContactGetMatches.OutputSchema>
>({
queryKey: findContactsGetMatchesQueryKey,
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData?.pages) {
for (const match of page.matches) {
if (match.did === did) {
yield match
}
}
}
}
const passthroughQueryDatas = queryClient.getQueriesData<Match[]>({
queryKey: [RQ_KEY_ROOT, 'passthrough'],
})
for (const [_queryKey, queryData] of passthroughQueryDatas) {
if (!queryData) {
continue
}
for (const match of queryData) {
if (match.profile.did === did) {
yield match.profile
}
}
}
}
+4 -1
View File
@@ -319,7 +319,10 @@ export function* findAllProfilesInQueryData(
}
for (const page of queryData?.pages) {
for (const item of page.items) {
if (item.type === 'follow' && item.notification.author.did === did) {
if (
(item.type === 'follow' || item.type === 'contact-match') &&
item.notification.author.did === did
) {
yield item.notification.author
} else if (
item.type !== 'starterpack-joined' &&
+1
View File
@@ -49,6 +49,7 @@ type OtherNotificationType =
| 'like-via-repost'
| 'repost-via-repost'
| 'subscribed-post'
| 'contact-match'
| 'unknown'
type FeedNotificationBase = {
+2 -1
View File
@@ -273,7 +273,8 @@ function toKnownType(
notif.reason === 'unverified' ||
notif.reason === 'like-via-repost' ||
notif.reason === 'repost-via-repost' ||
notif.reason === 'subscribed-post'
notif.reason === 'subscribed-post' ||
notif.reason === 'contact-match'
) {
return notif.reason as NotificationType
}
+12
View File
@@ -10,6 +10,8 @@ export enum Nux {
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
BookmarksAnnouncement = 'BookmarksAnnouncement',
FindContactsAnnouncement = 'FindContactsAnnouncement',
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -52,6 +54,14 @@ export type AppNux = BaseNux<
id: Nux.BookmarksAnnouncement
data: undefined
}
| {
id: Nux.FindContactsAnnouncement
data: undefined
}
| {
id: Nux.FindContactsDismissibleBanner
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -63,4 +73,6 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
[Nux.PolicyUpdate202508]: undefined,
[Nux.BookmarksAnnouncement]: undefined,
[Nux.FindContactsAnnouncement]: undefined,
[Nux.FindContactsDismissibleBanner]: undefined,
}