[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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' &&
|
||||
|
||||
@@ -49,6 +49,7 @@ type OtherNotificationType =
|
||||
| 'like-via-repost'
|
||||
| 'repost-via-repost'
|
||||
| 'subscribed-post'
|
||||
| 'contact-match'
|
||||
| 'unknown'
|
||||
|
||||
type FeedNotificationBase = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user