[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
+7
View File
@@ -389,6 +389,13 @@ module.exports = function (_config) {
],
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
['expo-location'],
[
'expo-contacts',
{
contactsPermission:
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
},
],
].filter(Boolean),
extra: {
eas: {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21A10 10 0 0 1 18 5.428V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z"/></svg>

After

Width:  |  Height:  |  Size: 349 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 484 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513a2.1 2.1 0 0 1 .806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12a2.1 2.1 0 0 1 .806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z"/></svg>

After

Width:  |  Height:  |  Size: 500 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z"/></svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z"/></svg>

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

-1
View File
@@ -308,7 +308,6 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
+2
View File
@@ -111,6 +111,8 @@ This is NOT required for app development but if you also want to develop the Blu
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
- Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start`
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
Then, when logging in or creating an account, point it to the localhost port of the devserver.
+37 -34
View File
@@ -72,10 +72,10 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.6",
"@atproto/api": "^0.18.8",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.5",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -132,43 +132,46 @@
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
"expo": "^54.0.22",
"expo-application": "~7.0.7",
"expo-blur": "~15.0.7",
"expo-build-properties": "~1.0.9",
"expo-camera": "~17.0.9",
"expo-clipboard": "~8.0.7",
"expo-dev-client": "~6.0.16",
"expo-device": "~8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo": "^54.0.27",
"expo-application": "~7.0.8",
"expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.8",
"expo-contacts": "^15.0.10",
"expo-dev-client": "~6.0.20",
"expo-device": "~8.0.10",
"expo-file-system": "~19.0.20",
"expo-font": "~14.0.10",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.8",
"expo-intent-launcher": "~13.0.7",
"expo-keep-awake": "^15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "~17.0.7",
"expo-location": "~19.0.7",
"expo-media-library": "~18.2.0",
"expo-notifications": "~0.32.12",
"expo-screen-orientation": "~9.0.7",
"expo-sharing": "~14.0.7",
"expo-splash-screen": "~31.0.10",
"expo-system-ui": "~6.0.8",
"expo-task-manager": "~14.0.8",
"expo-updates": "~29.0.12",
"expo-video": "~3.0.13",
"expo-web-browser": "~15.0.9",
"expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.9",
"expo-intent-launcher": "~13.0.8",
"expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.10",
"expo-localization": "~17.0.8",
"expo-location": "~19.0.8",
"expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.14",
"expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.8",
"expo-sms": "^14.0.7",
"expo-splash-screen": "~31.0.12",
"expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
"expo-web-browser": "~15.0.10",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.6.2",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
@@ -222,7 +225,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.193",
"@atproto/dev-env": "^0.3.196",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -264,7 +267,7 @@
"husky": "^8.0.3",
"is-ci": "^3.0.1",
"jest": "^29.7.0",
"jest-expo": "^54.0.14",
"jest-expo": "~54.0.14",
"jest-junit": "^16.0.0",
"lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0",
@@ -1,24 +1,3 @@
diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
index e9a8e3d..3c684e0 100644
--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
+++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
@@ -296,14 +296,13 @@ class LoaderTask(
) {
try {
val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory)
- val result = embeddedLoader.load { updateResponse ->
+ embeddedLoader.load { _ ->
Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
}
- launcher.launch(database)
} catch (e: Exception) {
logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown)
- launcher.launch(database)
}
+ launcher.launch(database)
} else {
launcher.launch(database)
}
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
index 68086bd..78c7761 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+23 -4
View File
@@ -18,7 +18,9 @@ import {
} from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {
getNotificationPayload,
type NotificationPayload,
@@ -45,10 +47,12 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {useCloseAllActiveElements} from '#/state/util'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
@@ -71,6 +75,7 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {BookmarksScreen} from '#/screens/Bookmarks'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList'
@@ -102,6 +107,7 @@ import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
@@ -134,10 +140,6 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
import {useLoggedOutViewControls} from './state/shell/logged-out'
import {useCloseAllActiveElements} from './state/util'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -416,6 +418,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="FindContactsSettings"
getComponent={() => FindContactsSettingsScreen}
options={{
title: title(msg`Find Contacts`),
requireAuth: true,
}}
/>
<Stack.Screen
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
@@ -609,6 +619,15 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="FindContactsFlow"
getComponent={() => FindContactsFlowScreen}
options={{
title: title(msg`Find Contacts`),
requireAuth: true,
gestureEnabled: false,
}}
/>
</>
)
}
@@ -4,6 +4,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type CountryCode,
getDefaultCountry,
INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes'
@@ -23,8 +24,8 @@ export function InternationalPhoneCodeSelect({
value,
onChange,
}: {
value?: string
onChange: (value: string) => void
value?: CountryCode
onChange: (value: CountryCode) => void
}) {
const {_, i18n} = useLingui()
const location = useGeolocation()
@@ -62,7 +63,7 @@ export function InternationalPhoneCodeSelect({
}, [value, items])
return (
<Select.Root value={value} onValueChange={onChange}>
<Select.Root value={value} onValueChange={onChange as (v: string) => void}>
<Select.Trigger label={_(msg`Select telephone code`)}>
<Select.ValueText placeholder="+..." webOverrideValue={selected}>
{selected => (
+3 -2
View File
@@ -38,7 +38,7 @@ export function Outer({
}: {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.MutableRefObject<View | null>
headerRef?: React.RefObject<View | null>
sticky?: boolean
}) {
const t = useTheme()
@@ -129,6 +129,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
@@ -183,7 +184,7 @@ export function TitleText({
<Text
style={[
a.text_lg,
a.font_bold,
a.font_semi_bold,
a.leading_tight,
isIOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl,
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text as TypoText} from '#/components/Typography'
+8 -2
View File
@@ -330,14 +330,20 @@ export function Name({
)
}
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
export function Handle({
profile,
textStyle,
}: {
profile: bsky.profile.AnyProfileView
textStyle?: StyleProp<TextStyle>
}) {
const t = useTheme()
const handle = sanitizeHandle(profile.handle, '@')
return (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
numberOfLines={1}>
{handle}
</Text>
@@ -31,7 +31,7 @@ import {
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
+1 -1
View File
@@ -12,7 +12,7 @@ import {
useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {
+4 -5
View File
@@ -1,10 +1,9 @@
import {View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
export function SearchError({
title,
@@ -14,7 +13,7 @@ export function SearchError({
children?: React.ReactNode
}) {
const {gtMobile} = useBreakpoints()
const pal = usePalette('default')
const t = useTheme()
return (
<Layout.Content>
@@ -27,7 +26,7 @@ export function SearchError({
paddingVertical: 150,
},
]}>
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
<XIcon width={32} style={[t.atoms.text_contrast_low]} />
<View
style={[
a.align_center,
@@ -0,0 +1,107 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {Link} from '../Link'
export function FindContactsBannerNUX() {
const t = useTheme()
const {_} = useLingui()
const {visible, close} = useInternalState()
if (!visible) return null
return (
<View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
<View style={a.w_full}>
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts to find your friends`)}
onPress={() => {
logger.metric('contacts:nux:bannerPressed', {})
}}
style={[
a.w_full,
a.rounded_xl,
a.curve_continuous,
a.overflow_hidden,
]}>
<LinearGradient
colors={[t.palette.primary_200, t.palette.primary_50]}
start={{x: 0, y: 0.5}}
end={{x: 1, y: 0.5}}
style={[
a.w_full,
a.h_full,
a.flex_row,
a.align_center,
a.gap_lg,
a.pl_lg,
]}>
<Image
source={require('../../../assets/images/find_friends_illustration_small.webp')}
accessibilityIgnoresInvertColors
style={[
{height: 70, aspectRatio: 573 / 286},
a.self_end,
a.mt_sm,
]}
/>
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
<Text
style={[
a.text_md,
a.font_bold,
{color: t.palette.primary_900},
]}>
<Trans>Import contacts to find your friends</Trans>
</Text>
</View>
</LinearGradient>
</Link>
<Button
label={_(msg`Dismiss banner`)}
hitSlop={HITSLOP_10}
onPress={close}
style={[a.absolute, {top: 14, right: 14}]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
<XIcon size="xs" style={[t.atoms.text_contrast_low]} />
</Button>
</View>
</View>
)
}
function useInternalState() {
const {nux} = useNux(Nux.FindContactsDismissibleBanner)
const {mutate: save, variables} = useSaveNux()
const hidden = !!variables
const visible = useMemo(() => {
if (isWeb) return false
if (hidden) return false
if (nux && nux.completed) return false
return true
}, [hidden, nux])
const close = () => {
save({
id: Nux.FindContactsDismissibleBanner,
completed: true,
data: undefined,
})
logger.metric('contacts:nux:bannerDismissed', {})
}
return {visible, close}
}
@@ -0,0 +1,56 @@
import {GetContacts} from './screens/GetContacts'
import {PhoneInput} from './screens/PhoneInput'
import {VerifyNumber} from './screens/VerifyNumber'
import {ViewMatches} from './screens/ViewMatches'
import {type Action, FindContactsGoBackContext, type State} from './state'
export function FindContactsFlow({
state,
dispatch,
onBack,
onCancel,
context = 'Standalone',
}: {
state: State
dispatch: React.ActionDispatch<[Action]>
onBack?: () => void
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}) {
return (
<FindContactsGoBackContext value={onBack}>
{state.step === '1: phone input' && (
<PhoneInput
state={state}
dispatch={dispatch}
context={context}
onSkip={onCancel}
/>
)}
{state.step === '2: verify number' && (
<VerifyNumber
state={state}
dispatch={dispatch}
context={context}
onSkip={onCancel}
/>
)}
{state.step === '3: get contacts' && (
<GetContacts
state={state}
dispatch={dispatch}
onCancel={onCancel}
context={context}
/>
)}
{state.step === '4: view matches' && (
<ViewMatches
state={state}
dispatch={dispatch}
context={context}
onNext={onCancel}
/>
)}
</FindContactsGoBackContext>
)
}
@@ -0,0 +1,3 @@
export function FindContactsFlow() {
throw new Error('FindContactsFlow is not available on web')
}
@@ -0,0 +1,33 @@
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
export function ContactsHeroImage() {
const t = useTheme()
const {_} = useLingui()
return (
<View
style={[
a.w_full,
a.pl_3xl,
a.pr_2xl,
a.pt_4xl,
a.pb_3xl,
a.rounded_lg,
{backgroundColor: t.palette.primary_50},
]}>
<Image
source={require('../../../../assets/images/find_friends_illustration.webp')}
accessibilityIgnoresInvertColors
style={[a.w_full, {aspectRatio: 1278 / 661}]}
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
/>
</View>
)
}
@@ -0,0 +1,85 @@
import {type StyleProp, type TextStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {android, atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
import {Text} from '#/components/Typography'
export function InviteInfo({
iconStyle,
iconOffset = 0,
}: {
iconStyle: StyleProp<TextStyle>
/**
* Adjust the vertical position of the icon via `translateY`
*
* @platform android
*/
iconOffset?: number
}) {
const {_} = useLingui()
const control = Dialog.useDialogControl()
const style = [a.text_md, a.leading_snug, a.mt_xs]
return (
<>
<Button
label={_(msg`Learn more about how inviting friends works`)}
onPress={control.open}
hitSlop={HITSLOP_20}
style={android({transform: [{translateY: iconOffset}]})}>
<InfoIcon style={iconStyle} size="sm" />
</Button>
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Invite Friends`)}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Invite Friends</Trans>
</Text>
<Text style={style}>
<Trans>
It looks like some of your contacts have not tried to find you
here yet. You can personally invite them by customizing a draft
message we will provide.
</Trans>
</Text>
<Text style={[style, a.font_medium, a.mt_lg]}>
<Trans>How it works:</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Choose who to invite</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Personalize the message</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Send the message from your phone</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>
We don't store your friends' phone numbers or send any messages
</Trans>
</Text>
<Button
label={_(msg`Done`)}
onPress={() => control.close()}
size="large"
color="primary"
style={[a.mt_2xl]}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
@@ -0,0 +1,143 @@
import {useRef, useState} from 'react'
import {
Pressable,
TextInput,
type TextInputSelectionChangeEvent,
View,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {mergeRefs} from '#/lib/merge-refs'
import {isAndroid, isIOS} from '#/platform/detection'
import {atoms as a, ios, platform, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
export function OTPInput({
label,
value,
onChange,
ref,
numberOfDigits = 6,
onComplete,
}: {
label: string
value: string
onChange: (text: string) => void
ref?: React.Ref<TextInput>
numberOfDigits?: number
onComplete?: (code: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const innerRef = useRef<TextInput>(null)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [selection, setSelection] = useState({start: 0, end: 0})
const onChangeText = (text: string) => {
// only numbers
text = text.replace(/[^0-9]/g, '')
text = text.slice(0, numberOfDigits)
onChange(text)
if (text.length === numberOfDigits) {
onComplete?.(text)
innerRef.current?.blur()
}
}
const onSelectionChange = (evt: TextInputSelectionChangeEvent) => {
setSelection(evt.nativeEvent.selection)
}
return (
<Pressable
accessibilityLabel={_(msg`Focus code input`)}
accessibilityRole="button"
accessibilityHint=""
style={[a.w_full, a.relative]}
onPress={() => {
innerRef.current?.focus()
innerRef.current?.clear()
}}>
<View style={[a.w_full, a.flex_row, a.gap_sm]}>
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
const selected = focused
? selection.start === selection.end
? selection.start === index
: index >= selection.start && index < selection.end
: false
return (
<View
key={index}
style={[
a.flex_1,
a.align_center,
a.justify_center,
t.atoms.bg_contrast_50,
{
height: 64,
borderWidth: 1,
borderRadius: 10,
borderColor: selected
? t.palette.primary_500
: t.atoms.bg_contrast_50.backgroundColor,
},
]}>
<Text style={[a.text_2xl, a.text_center, a.font_bold]}>
{digit}
</Text>
</View>
)
})}
</View>
<TextInput
// SMS autofill is borked on iOS if you open the keyboard immediately -sfn
onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))}
autoFocus={isAndroid}
accessible
accessibilityLabel={label}
accessibilityHint=""
accessibilityRole="text"
ref={mergeRefs(ref ? [ref, innerRef] : [innerRef])}
value={value}
onChangeText={onChangeText}
onSelectionChange={onSelectionChange}
keyboardAppearance={t.scheme}
inputMode="numeric"
keyboardType="number-pad"
textContentType="oneTimeCode"
autoComplete={platform({
android: 'sms-otp',
ios: 'one-time-code',
})}
onFocus={onFocus}
onBlur={onBlur}
maxLength={numberOfDigits}
style={[
a.absolute,
a.inset_0,
// roughly vibe align the characters
// with the visible ones so that
// moving the caret via long press
// still kinda sorta works
{
fontVariant: ['tabular-nums'],
textAlignVertical: 'center',
letterSpacing: 24,
fontSize: 60,
paddingLeft: 6,
},
platform({
// completely transparent inputs on iOS cannot be pasted into
ios: {opacity: 0.02, color: 'transparent'},
android: {opacity: 0},
}),
]}
caretHidden={isIOS}
clearTextOnFocus
/>
</Pressable>
)
}
+100
View File
@@ -0,0 +1,100 @@
import {type AppBskyContactDefs} from '@atproto/api'
import {type CountryCode} from '#/lib/international-telephone-codes'
import {normalizePhoneNumber} from './phone-number'
import {type Contact, type Match} from './state'
/**
* Filters out contacts that do not have any associated phone numbers,
* as well as businesses
*/
export function contactsWithPhoneNumbersOnly(contacts: Contact[]) {
return contacts.filter(
contact =>
contact.phoneNumbers &&
contact.phoneNumbers.length > 0 &&
contact.contactType !== 'company',
)
}
/**
* Takes the raw contact book and returns a plain list of numbers in E.164 format, along
* with a mapping to retrieve the contact ID when we get the results back.
*
* `countryCode` is used as a fallback for local numbers that don't have a country code associated with them.
* I'm making the assumption that most local numbers in someone's phone book will be the same as theirs.
*/
export function normalizeContactBook(
contacts: Contact[],
countryCode: CountryCode,
ownNumber: string,
): {
phoneNumbers: string[]
indexToContactId: Map<number, Contact['id']>
} {
const phoneNumbers: string[] = []
const indexToContactId = new Map<number, Contact['id']>()
for (const contact of contacts) {
for (const number of contact.phoneNumbers ?? []) {
let rawNumber: string
if (number.number) {
rawNumber = number.number
} else if (number.digits) {
rawNumber = number.digits
} else {
continue
}
const normalized = normalizePhoneNumber(
rawNumber,
number.countryCode,
countryCode,
)
if (normalized === null) continue
// skip if it's your own number
if (normalized === ownNumber) continue
phoneNumbers.push(normalized)
indexToContactId.set(phoneNumbers.length - 1, contact.id)
}
}
return {
phoneNumbers,
indexToContactId,
}
}
export function filterMatchedNumbers(
contacts: Contact[],
results: AppBskyContactDefs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
) {
const filteredIds = new Set<Contact['id']>()
for (const result of results) {
const id = mapping.get(result.contactIndex)
if (id !== undefined) {
filteredIds.add(id)
}
}
return contacts.filter(contact => !filteredIds.has(contact.id))
}
export function getMatchedContacts(
contacts: Contact[],
results: AppBskyContactDefs.MatchAndContactIndex[],
mapping: Map<number, Contact['id']>,
): Array<Match> {
const contactsById = new Map(contacts.map(c => [c.id, c]))
return results.map(result => {
const id = mapping.get(result.contactIndex)
const contact = id !== undefined ? contactsById.get(id) : undefined
return {profile: result.match, contact}
})
}
@@ -0,0 +1,37 @@
import {type CountryCode} from '#/lib/international-telephone-codes'
import {IS_DEV} from '#/env'
import {useGeolocation} from '#/geolocation'
const FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST = [
'US',
'GB',
'JP',
'CA',
'DE',
'FR',
'ES',
'BR',
'KR',
'NL',
'AU',
'SE',
'IT',
] satisfies CountryCode[] as string[]
export function isFindContactsFeatureEnabled(countryCode: string): boolean {
return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes(
countryCode.toUpperCase(),
)
}
export function useIsFindContactsFeatureEnabledBasedOnGeolocation() {
const location = useGeolocation()
if (IS_DEV) return true
// they can try, by they'll need a phone number
// from one of the allowlisted countries
if (!location.countryCode) return true
return isFindContactsFeatureEnabled(location.countryCode)
}
+162
View File
@@ -0,0 +1,162 @@
import {t} from '@lingui/macro'
import {
isSupportedCountry,
ParseError,
parsePhoneNumber,
parsePhoneNumberWithError,
type PhoneNumber,
} from 'libphonenumber-js/max'
import {type CountryCode} from '#/lib/international-telephone-codes'
/**
* Intended for after the user has finished inputting their phone number.
*/
export function processPhoneNumber(
number: string,
country: CountryCode,
):
| {
valid: true
formatted: string
countryCode: CountryCode
}
| {
valid: false
reason?: string
} {
try {
const phoneNumber = parsePhoneNumberWithError(number, {
defaultCountry: country,
})
if (!phoneNumber.isValid()) {
return {valid: false, reason: t`Invalid phone number`}
}
const type = phoneNumber.getType()
if (
type !== 'MOBILE' &&
type !== 'FIXED_LINE_OR_MOBILE' &&
type !== 'PERSONAL_NUMBER'
) {
return {
valid: false,
reason: t`Number should be a mobile number`,
}
}
let countryCode = country
if (phoneNumber.country && phoneNumber.country !== country) {
if (phoneNumber.country === 'AC' || phoneNumber.country === 'TA') {
countryCode = 'SH'
} else {
countryCode = phoneNumber.country
}
}
return {
valid: true,
formatted: formatE164lWithoutCountryCode(phoneNumber),
countryCode,
}
} catch (error) {
if (error instanceof ParseError) {
return {valid: false, reason: error.message}
} else {
return {valid: false}
}
}
}
/**
* Format a phone number as the international format with the prefix
* removed.
*/
function formatE164lWithoutCountryCode(phoneNumber: PhoneNumber) {
const intl = phoneNumber.format('E.164')
const prefix = '+' + phoneNumber.countryCallingCode
return intl.replace(prefix, '').trim()
}
/**
* Takes a country code and a prefix-less phone number and constructs a full phone number.
*
* Does not have nice error handling - if you're unsure if the number is valid, use
* `processPhoneNumber` instead
*/
export function constructFullPhoneNumber(
countryCode: CountryCode,
phoneNumber: string,
) {
const result = parsePhoneNumber(phoneNumber, {defaultCountry: countryCode})
if (!result.isValid())
throw new Error('Invalid phone number passed to constructFullPhoneNumber')
return result.format('E.164')
}
/**
* Takes a phone number and applies human-readable formatting. Do not sent to the API - they
* expect E.164 format.
*/
export function prettyPhoneNumber(phoneNumber: string) {
const result = parsePhoneNumber(phoneNumber)
return result.formatNational()
}
/**
* Attempts to parse a phone number from a string, and returns the country code
* and the rest of the number if possible. If the number is invalid, returns undefined.
*/
export function getCountryCodeFromPastedNumber(
text: string,
): {countryCode: CountryCode; rest: string} | undefined {
try {
const phoneNumber = parsePhoneNumber(text)
if (!phoneNumber.isValid()) {
return undefined
}
const countryCode = phoneNumber.country
// we don't have AC and TA in our dropdown - see `#/lib/international-telephone-codes`
if (countryCode && countryCode !== 'AC' && countryCode !== 'TA') {
return {
countryCode,
rest: formatE164lWithoutCountryCode(phoneNumber),
}
} else {
return undefined
}
} catch (error) {
return undefined
}
}
/**
* Normalizes a phone number into E.164 format
*/
export function normalizePhoneNumber(
rawNumber: string,
countryCode: string | undefined,
fallbackCountryCode: CountryCode,
): string | null {
try {
const result = parsePhoneNumber(rawNumber, {
defaultCountry:
countryCode && isSupportedCountry(countryCode)
? countryCode
: fallbackCountryCode,
})
if (!result.isValid()) return null
const type = result.getType()
if (
type !== 'MOBILE' &&
type !== 'FIXED_LINE_OR_MOBILE' &&
type !== 'PERSONAL_NUMBER'
) {
return null
}
return result.format('E.164')
} catch (error) {
console.log('Failed to normalize phone number:', error)
return null
}
}
@@ -0,0 +1,290 @@
import {Alert, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as Contacts from 'expo-contacts'
import {AppBskyContactImportContacts} from '@atproto/api'
import {msg, t, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
import {useAgent} from '#/state/session'
import {atoms as a, ios, tokens, useGutters} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {
contactsWithPhoneNumbersOnly,
filterMatchedNumbers,
getMatchedContacts,
normalizeContactBook,
} from '../contacts'
import {constructFullPhoneNumber} from '../phone-number'
import {type Action, type State} from '../state'
const MAX_UPLOAD_COUNT = 1000
export function GetContacts({
state,
dispatch,
onCancel,
context,
}: {
state: Extract<State, {step: '3: get contacts'}>
dispatch: React.ActionDispatch<[Action]>
onCancel: () => void
context: 'Onboarding' | 'Standalone'
}) {
const {_} = useLingui()
const agent = useAgent()
const insets = useSafeAreaInsets()
const gutters = useGutters([0, 'wide'])
const queryClient = useQueryClient()
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
const {phoneNumbers, indexToContactId} = normalizeContactBook(
contacts,
state.phoneCountryCode,
constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
)
if (phoneNumbers.length > 0) {
const res = await agent.app.bsky.contact.importContacts({
token: state.token,
contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT),
})
return {
matches: res.data.matchesAndContactIndexes,
indexToContactId,
}
} else {
return {
matches: [],
indexToContactId,
}
}
},
onSuccess: (result, contacts) => {
if (context === 'Onboarding') {
logger.metric('onboarding:contacts:contactsShared', {})
}
if (result.matches.length > 0) {
logger.metric('contacts:import:success', {
contactCount: contacts.length,
matchCount: result.matches.length,
entryPoint: context,
})
} else {
logger.metric('contacts:import:failure', {
reason: 'noValidNumbers',
entryPoint: context,
})
}
dispatch({
type: 'SYNC_CONTACTS_SUCCESS',
payload: {
matches: getMatchedContacts(
contacts,
result.matches,
result.indexToContactId,
),
contacts: filterMatchedNumbers(
contacts,
result.matches,
result.indexToContactId,
),
},
})
queryClient.invalidateQueries({
queryKey: findContactsStatusQueryKey,
})
},
onError: err => {
logger.metric('contacts:import:failure', {
reason: isNetworkError(err) ? 'networkError' : 'unknown',
entryPoint: context,
})
if (isNetworkError(err)) {
Toast.show(
_(
msg`There was a problem with your internet connection, please try again`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.TooManyContactsError
) {
Toast.show(
_(
msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`,
),
{type: 'error'},
)
} else if (
err instanceof AppBskyContactImportContacts.InvalidTokenError
) {
Toast.show(
_(
msg`Could not upload contacts. You need to re-verify your phone number to proceed`,
),
{type: 'error'},
)
} else {
logger.error('Error uploading contacts', {safeMessage: err})
Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({
mutationFn: async () => {
let permissions = await Contacts.getPermissionsAsync()
if (!permissions.granted && permissions.canAskAgain) {
permissions = await Contacts.requestPermissionsAsync()
}
logger.metric('contacts:permission:request', {
status: permissions.granted ? 'granted' : 'denied',
accessLevelIOS: ios(permissions.accessPrivileges),
})
if (!permissions.granted) {
throw new PermissionDeniedError()
}
const contacts = await Contacts.getContactsAsync({
fields: [
Contacts.Fields.FirstName,
Contacts.Fields.LastName,
Contacts.Fields.PhoneNumbers,
Contacts.Fields.Image,
],
})
return contactsWithPhoneNumbersOnly(contacts.data)
},
onSuccess: contacts => {
dispatch({
type: 'GET_CONTACTS_SUCCESS',
payload: {contacts},
})
uploadContacts(contacts)
},
onError: err => {
if (err instanceof PermissionDeniedError) {
showPermissionDeniedAlert()
} else {
logger.error('Error getting contacts', {safeMessage: err})
}
},
})
const isPending = isUploadPending || isGetContactsPending
const style = [a.text_md, a.leading_snug, a.mt_md]
return (
<View style={[a.h_full]}>
<Layout.Content
contentContainerStyle={[gutters, a.flex_1, a.pt_xl]}
bounces={false}>
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Share your contacts to find friends</Trans>
</Text>
<Text style={style}>
<Trans>
Bluesky helps friends find each other by creating an encoded digital
fingerprint, called a "hash," and then looking for matching hashes.
</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We never store plain phone numbers</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We delete hashes after matches are made</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We only suggest follows if both people consent</Trans>
</Text>
<Text style={style}>
&bull; <Trans>You can always opt out and delete your data</Trans>
</Text>
<Text style={[style, a.mt_lg]}>
<Trans>
We apply the highest privacy standards, and never share or sell your
contact information.
</Trans>
</Text>
</Layout.Content>
<View
style={[
gutters,
a.pt_xs,
{paddingBottom: Math.max(insets.bottom, tokens.space.xl)},
a.gap_md,
]}>
<Text style={[a.text_sm, a.pb_xs]}>
<Trans>
I consent to Bluesky using my contacts for mutual friend discovery
and to retain hashed data for matching until I opt out.
</Trans>
</Text>
<Button
label={_(msg`Find my friends`)}
size="large"
color="primary"
onPress={() => getContacts()}
disabled={isPending}>
{isUploadPending ? (
<>
<ButtonText>
<Trans>Finding friends...</Trans>
</ButtonText>
<ButtonIcon icon={Loader} />
</>
) : (
<ButtonText>
<Trans>Find my friends</Trans>
</ButtonText>
)}
</Button>
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary"
onPress={onCancel}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
class PermissionDeniedError extends Error {
constructor() {
super('Permission denied')
}
}
function showPermissionDeniedAlert() {
Alert.alert(
t`You've denied access to your contacts`,
t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`,
[
{
text: t`OK`,
style: 'default',
},
],
)
}
@@ -0,0 +1,326 @@
import {useState} from 'react'
import {Keyboard, View} from 'react-native'
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyContactStartPhoneVerification} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {
type CountryCode,
getDefaultCountry,
} from '#/lib/international-telephone-codes'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {
android,
atoms as a,
platform,
tokens,
useGutters,
useTheme,
} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useGeolocation} from '#/geolocation'
import {isFindContactsFeatureEnabled} from '../country-allowlist'
import {
constructFullPhoneNumber,
getCountryCodeFromPastedNumber,
processPhoneNumber,
} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function PhoneInput({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '1: phone input'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const location = useGeolocation()
const [countryCode, setCountryCode] = useState(
() => state.phoneCountryCode ?? getDefaultCountry(location),
)
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
const gutters = useGutters([0, 'wide'])
const insets = useSafeAreaInsets()
// for API/generic errors
const [error, setError] = useState('')
// for issues with parsing the number
const [formatError, setFormatError] = useState('')
const {mutate: submit, isPending} = useMutation({
mutationFn: async ({
phoneCountryCode,
phoneNumber,
}: {
phoneCountryCode: CountryCode
phoneNumber: string
}) => {
// sends a onetime code to the user's phone number
await agent.app.bsky.contact.startPhoneVerification({
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
})
},
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
dispatch({
type: 'SUBMIT_PHONE_NUMBER',
payload: {phoneCountryCode, phoneNumber},
})
logger.metric('contacts:phone:phoneEntered', {entryPoint: context})
},
onMutate: () => {
Keyboard.dismiss()
setError('')
setFormatError('')
},
onError: err => {
if (isNetworkError(err)) {
setError(
_(
msg`A network error occurred. Please check your internet connection`,
),
)
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError(_(msg`Rate limit exceeded. Please try again later.`))
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError(
_(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
)
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError(_(msg`An error occurred. ${cleanError(err)}`))
}
},
})
const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode)
const onSubmitNumber = () => {
if (!isFeatureEnabled) return
if (!phoneNumber) return
const result = processPhoneNumber(phoneNumber, countryCode)
if (result.valid) {
setPhoneNumber(result.formatted)
setCountryCode(result.countryCode)
if (!isFindContactsFeatureEnabled(result.countryCode)) return
submit({
phoneCountryCode: result.countryCode,
phoneNumber: result.formatted,
})
} else {
setFormatError(result.reason ?? _(msg`Invalid phone number`))
}
}
const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="handled">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>
We need to verify your number before we can look for your friends. A
verification code will be sent to this number.
</Trans>
</Text>
<View style={[a.mt_2xl]}>
<TextField.LabelText>
<Trans>Phone number</Trans>
</TextField.LabelText>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
<View>
<InternationalPhoneCodeSelect
value={countryCode}
onChange={value => setCountryCode(value)}
/>
</View>
<View style={[a.flex_1]}>
<TextField.Root isInvalid={!!formatError || !isFeatureEnabled}>
<TextField.Input
label={_(msg`Phone number`)}
value={phoneNumber}
onChangeText={text => {
if (formatError) setFormatError('')
if (Math.abs(text.length - phoneNumber.length) > 1) {
// possibly pasted/autocompleted? auto-switch
// country code if possible
const result = getCountryCodeFromPastedNumber(text)
if (result) {
setCountryCode(result.countryCode)
setPhoneNumber(result.rest)
return
}
}
setPhoneNumber(text)
}}
placeholder={null}
keyboardType={platform({
ios: 'number-pad',
android: 'phone-pad',
})}
autoComplete="tel"
returnKeyType={android('next')}
onSubmitEditing={onSubmitNumber}
/>
</TextField.Root>
</View>
</View>
</View>
{!isFeatureEnabled && (
<ErrorText>
<Trans>
Support for this feature in your country has not been enabled yet!
Please check back later.
</Trans>
</ErrorText>
)}
{error && <ErrorText>{error}</ErrorText>}
{formatError && <ErrorText>{formatError}</ErrorText>}
<View style={[a.mt_auto, a.py_xl]}>
<LegalDisclaimer />
</View>
</Layout.Content>
<KeyboardAvoidingView
behavior="padding"
keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
<View style={[gutters, {paddingBottom}]}>
<Button
disabled={!phoneNumber || isPending}
label={_(msg`Send code`)}
size="large"
color="primary"
onPress={onSubmitNumber}>
<ButtonText>
<Trans>Send code</Trans>
</ButtonText>
{isPending && <ButtonIcon icon={Loader} />}
</Button>
</View>
</KeyboardAvoidingView>
</View>
)
}
function LegalDisclaimer() {
const t = useTheme()
const {_} = useLingui()
const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
return (
<View style={[a.gap_xs]}>
<Text style={[style, a.font_medium]}>
<Trans>How we use your number:</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Sent to our phone number verification provider Plivo</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Deleted by Plivo after verification</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Stored as part of a secure code for matching with others</Trans>
</Text>
<Text style={[style, a.mt_xs]}>
<Trans>
By continuing, you consent to this use. You may change your mind any
time by visiting settings.{' '}
<InlineLinkText
to="#"
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_xs, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</Text>
</View>
)
}
function ErrorText({children}: {children: React.ReactNode}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
{color: t.palette.negative_500},
a.leading_snug,
a.mt_md,
]}>
{children}
</Text>
)
}
@@ -0,0 +1,386 @@
import {useEffect, useMemo, useState} from 'react'
import {Text as NestedText, View} from 'react-native'
import {
AppBskyContactStartPhoneVerification,
AppBskyContactVerifyPhone,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {clamp} from '#/lib/numbers'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {atoms as a, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {OTPInput} from '../components/OTPInput'
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
import {type Action, type State, useOnPressBackButton} from '../state'
export function VerifyNumber({
state,
dispatch,
context,
onSkip,
}: {
state: Extract<State, {step: '2: verify number'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onSkip: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gutters = useGutters([0, 'wide'])
const [otpCode, setOtpCode] = useState('')
const [error, setError] = useState<{
retryable: boolean
isResendError: boolean
message: string
} | null>(null)
const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
if (otpCode !== prevOtpCode) {
setPrevOtpCode(otpCode)
setError(null)
}
const phone = useMemo(
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
[state.phoneCountryCode, state.phoneNumber],
)
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
const {
mutate: verifyNumber,
isPending,
isSuccess,
} = useMutation({
mutationFn: async (code: string) => {
const res = await agent.app.bsky.contact.verifyPhone({code, phone})
return res.data.token
},
onSuccess: async token => {
// let the success state show for a moment
setTimeout(() => {
dispatch({
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
payload: {
token,
},
})
}, 1000)
logger.metric('contacts:phone:phoneVerified', {entryPoint: context})
},
onMutate: () => setError(null),
onError: err => {
setOtpCode('')
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) {
setError({
retryable: true,
isResendError: true,
message: _(msg`This code is invalid. Resend to get a new code.`),
})
} else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
setError({
retryable: false,
isResendError: false,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: false,
message: _(
msg`Too many attempts. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Verify phone number failed', {safeMessage: err})
setError({
retryable: true,
isResendError: false,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
mutationFn: async () => {
await agent.app.bsky.contact.startPhoneVerification({phone: phone})
},
onSuccess: () => {
dispatch({type: 'RESEND_VERIFICATION_CODE'})
Toast.show(_(msg`A new code has been sent`))
},
onMutate: () => {
setOtpCode('')
setError(null)
},
onError: err => {
if (isNetworkError(err)) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`A network error occurred. Please check your internet connection.`,
),
})
} else if (
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
) {
setError({
retryable: false,
isResendError: true,
message: _(
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
),
})
} else if (
err instanceof
AppBskyContactStartPhoneVerification.RateLimitExceededError
) {
setError({
retryable: true,
isResendError: true,
message: _(
msg`Too many codes sent. Please wait a few minutes and try again.`,
),
})
} else {
logger.error('Resend failed', {safeMessage: err})
setError({
retryable: true,
isResendError: true,
message: _(msg`An error occurred. ${cleanError(err)}`),
})
}
},
})
const onPressBack = useOnPressBackButton()
return (
<View style={[a.h_full]}>
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton onPress={onPressBack} />
<Layout.Header.Content />
{context === 'Onboarding' ? (
<Button
size="small"
color="secondary"
variant="ghost"
label={_(msg`Skip contact sharing and continue to the app`)}
onPress={onSkip}>
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
) : (
<Layout.Header.Slot />
)}
</Layout.Header.Outer>
<Layout.Content
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
keyboardShouldPersistTaps="always">
{context === 'Onboarding' && <OnboardingPosition />}
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>Verify phone number</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.mt_sm,
]}>
<Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
</Text>
<View style={[a.mt_2xl]}>
<OTPInput
label={_(
msg`Enter 6-digit code that was sent to your phone number`,
)}
value={otpCode}
onChange={setOtpCode}
onComplete={code => verifyNumber(code)}
/>
</View>
<View style={[a.mt_sm]}>
<OTPStatus
error={error}
isPending={isPending}
isResendingCode={isResendingCode}
isSuccess={isSuccess}
onResend={() => resendCode()}
onRetry={() => verifyNumber(otpCode)}
lastCodeSentAt={state.lastSentAt}
/>
</View>
</Layout.Content>
</View>
)
}
/**
* Horrible component that takes all the state above and figures out what messages
* and buttons to display.
*/
function OTPStatus({
error,
isPending,
isResendingCode,
isSuccess,
onResend,
onRetry,
lastCodeSentAt,
}: {
error: {
retryable: boolean
isResendError: boolean
message: string
} | null
isPending: boolean
isResendingCode: boolean
isSuccess: boolean
onResend: () => void
onRetry: () => void
lastCodeSentAt: Date | null
}) {
const {_} = useLingui()
const t = useTheme()
const [time, setTime] = useState(Date.now())
useEffect(() => {
const interval = setInterval(() => {
setTime(Date.now())
}, 1000)
return () => clearInterval(interval)
}, [])
const timeUntilCanResend = Math.max(
0,
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
)
const isWaiting = timeUntilCanResend > 0
let Icon: React.ComponentType<SVGIconProps> | null = null
let text = ''
let textColor = t.atoms.text_contrast_medium.color
let showResendButton = false
let showRetryButton = false
if (isSuccess) {
Icon = CircleCheckIcon
text = _(msg`Phone number verified`)
textColor = t.palette.positive_500
} else if (isPending) {
text = _(msg`Verifying...`)
} else if (error) {
Icon = WarningIcon
text = error.message
textColor = t.palette.negative_500
if (error.retryable) {
if (error.isResendError) {
showResendButton = true
} else {
showRetryButton = true
}
}
} else {
showResendButton = true
}
return (
<View style={[a.w_full, a.align_center]}>
{text && (
<View
style={[
a.gap_xs,
a.flex_row,
a.align_center,
(isSuccess || isPending) && a.mt_lg,
]}>
{Icon && <Icon size="xs" style={{color: textColor}} />}
<Text
style={[
{color: textColor},
a.text_sm,
a.leading_snug,
a.text_center,
]}>
{text}
</Text>
</View>
)}
{showRetryButton && (
<Button
size="small"
color="secondary_inverted"
label={_(msg`Retry`)}
onPress={onRetry}
style={[a.mt_2xl]}>
<ButtonIcon icon={RetryIcon} />
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
</Button>
)}
{showResendButton && (
<Button
size="large"
color="secondary"
variant="ghost"
label={_(msg`Resend code`)}
disabled={isResendingCode || isWaiting}
onPress={onResend}
style={[a.mt_2xl]}>
{isResendingCode && <ButtonIcon icon={Loader} />}
<ButtonText>
{isWaiting ? (
<Trans>
Resend code in{' '}
<NestedText style={{fontVariant: ['tabular-nums']}}>
00:
{String(
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
).padStart(2, '0')}
</NestedText>
</Trans>
) : (
<Trans>Resend code</Trans>
)}
</ButtonText>
</Button>
)}
</View>
)
}
@@ -0,0 +1,683 @@
import {useCallback, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as SMS from 'expo-sms'
import {type ModerationOpts} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {
updateProfileShadow,
useProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
optimisticRemoveMatch,
useMatchesPassthroughQuery,
} from '#/state/queries/find-contacts'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {SearchInput} from '#/components/forms/SearchInput'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass'
import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteInfo} from '../components/InviteInfo'
import {type Action, type Contact, type Match, type State} from '../state'
type Item =
| {
type: 'matches header'
count: number
}
| {
type: 'match'
match: Match
}
| {
type: 'contacts header'
}
| {
type: 'contact'
contact: Contact
}
| {
type: 'no matches header'
}
| {
type: 'search empty state'
query: string
}
| {
type: 'totally empty state'
}
export function ViewMatches({
state,
dispatch,
context,
onNext,
}: {
state: Extract<State, {step: '4: view matches'}>
dispatch: React.ActionDispatch<[Action]>
context: 'Onboarding' | 'Standalone'
onNext: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const gutter = useGutters([0, 'wide'])
const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
const agent = useAgent()
const insets = useSafeAreaInsets()
const listRef = useRef<ListMethods>(null)
const [search, setSearch] = useState('')
const {
state: searchFocused,
onIn: onFocus,
onOut: onBlur,
} = useInteractionState()
// HACK: Although we already have the match data, we need to pass it through
// a query to get it into the shadow state
const allMatches = useMatchesPassthroughQuery(state.matches)
const matches = allMatches.filter(
match => !state.dismissedMatches.includes(match.profile.did),
)
console.log(matches)
const followableDids = matches.map(match => match.profile.did)
const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
const cumulativeFollowCount = useRef(0)
const onFollow = useCallback(() => {
logger.metric('contacts:matches:follow', {entryPoint: context})
cumulativeFollowCount.current += 1
}, [context])
const {mutate: followAll, isPending: isFollowingAll} = useMutation({
mutationFn: async () => {
for (const did of followableDids) {
updateProfileShadow(queryClient, did, {
followingUri: 'pending',
})
}
const uris = await wait(500, bulkWriteFollows(agent, followableDids))
for (const did of followableDids) {
const uri = uris.get(did)
updateProfileShadow(queryClient, did, {
followingUri: uri,
})
}
return followableDids
},
onMutate: () =>
logger.metric('contacts:matches:followAll', {
followCount: followableDids.length,
entryPoint: context,
}),
onSuccess: () => {
setDidFollowAll(true)
Toast.show(_(msg`All friends followed!`), {type: 'success'})
cumulativeFollowCount.current += followableDids.length
},
onError: _err => {
Toast.show(_(msg`Failed to follow all your friends, please try again`), {
type: 'error',
})
for (const did of followableDids) {
updateProfileShadow(queryClient, did, {
followingUri: undefined,
})
}
},
})
const items = useMemo(() => {
const all: Item[] = []
if (searchFocused || search.length > 0) {
for (const match of matches) {
if (
search.length === 0 ||
(match.profile.displayName ?? '')
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()) ||
match.profile.handle
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) {
all.push({type: 'match', match})
}
}
for (const contact of state.contacts) {
if (
search.length === 0 ||
[contact.firstName, contact.lastName]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase())
) {
all.push({type: 'contact', contact})
}
}
if (all.length === 0) {
all.push({type: 'search empty state', query: search})
}
} else {
if (matches.length > 0) {
all.push({type: 'matches header', count: matches.length})
for (const match of matches) {
all.push({type: 'match', match})
}
if (state.contacts.length > 0) {
all.push({type: 'contacts header'})
}
} else if (state.contacts.length > 0) {
all.push({type: 'no matches header'})
}
for (const contact of state.contacts) {
all.push({type: 'contact', contact})
}
if (all.length === 0) {
all.push({type: 'totally empty state'})
}
}
return all
}, [matches, state.contacts, search, searchFocused])
const {mutate: dismissMatch} = useMutation({
mutationFn: async (did: string) => {
await agent.app.bsky.contact.dismissMatch({subject: did})
},
onMutate: did => {
logger.metric('contacts:matches:dismiss', {entryPoint: context})
dispatch({type: 'DISMISS_MATCH', payload: {did}})
},
onSuccess: (_res, did) => {
// for the other screen
optimisticRemoveMatch(queryClient, did)
},
onError: (err, did) => {
dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}})
if (isNetworkError(err)) {
Toast.show(
_(
msg`Failed to hide suggestion, please check your internet connection`,
),
{type: 'error'},
)
} else {
logger.error('Dismissing match failed', {safeMessage: err})
Toast.show(
_(msg`An error occurred while hiding suggestion. ${cleanError(err)}`),
{type: 'error'},
)
}
},
})
const renderItem = ({item}: {item: Item}) => {
switch (item.type) {
case 'match':
return (
<MatchItem
profile={item.match.profile}
contact={item.match.contact}
moderationOpts={moderationOpts}
onRemoveSuggestion={dismissMatch}
onFollow={onFollow}
/>
)
case 'contact':
return <ContactItem contact={item.contact} context={context} />
case 'matches header':
return (
<Header
titleText={
<Plural
value={item.count}
one="# friend found!"
other="# friends found!"
/>
}>
{item.count > 1 && (
<Button
label={_(msg`Follow all`)}
size="small"
color="primary_subtle"
onPress={() => followAll()}
disabled={isFollowingAll || didFollowAll}>
<ButtonIcon
icon={
isFollowingAll
? Loader
: !didFollowAll
? PlusIcon
: CheckIcon
}
/>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
</Button>
)}
</Header>
)
case 'contacts header':
return (
<Header
titleText={
<Trans>
Invite friends{' '}
<InviteInfo iconStyle={t.atoms.text} iconOffset={1} />
</Trans>
}
hasContentAbove
/>
)
case 'no matches header':
return (
<Header
titleText={_(msg`You got here first`)}
largeTitle
subtitleText={
<Trans>
Bluesky is more fun with friends. Do you want to invite some of
yours?{' '}
<InviteInfo
iconStyle={t.atoms.text_contrast_medium}
iconOffset={2}
/>
</Trans>
}
/>
)
case 'search empty state':
return <SearchEmptyState query={item.query} />
case 'totally empty state':
return <TotallyEmptyState />
}
}
const isSearchEmpty = items?.[0]?.type === 'search empty state'
const isTotallyEmpty = items?.[0]?.type === 'totally empty state'
const isEmpty = isSearchEmpty || isTotallyEmpty
return (
<View style={[a.h_full]}>
{context === 'Standalone' && (
<Layout.Header.Outer noBottomBorder>
<Layout.Header.BackButton />
<Layout.Header.Content />
<Layout.Header.Slot />
</Layout.Header.Outer>
)}
{!isTotallyEmpty && (
<View
style={[
gutter,
a.mb_md,
context === 'Onboarding' && [a.mt_sm, a.gap_sm],
]}>
{context === 'Onboarding' && <OnboardingPosition />}
<SearchInput
placeholder={_(msg`Search contacts`)}
value={search}
onFocus={() => {
onFocus()
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onBlur={() => {
onBlur()
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onClearText={() => setSearch('')}
/>
</View>
)}
<List
ref={listRef}
data={items}
renderItem={renderItem}
ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null}
keyExtractor={keyExtractor}
keyboardDismissMode="interactive"
automaticallyAdjustKeyboardInsets
/>
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_t,
a.align_center,
a.align_stretch,
gutter,
a.pt_md,
{paddingBottom: insets.bottom + tokens.space.md},
]}>
<Button
label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
onPress={() => {
if (context === 'Onboarding') {
logger.metric('onboarding:contacts:nextPressed', {
matchCount: allMatches.length,
followCount: cumulativeFollowCount.current,
dismissedMatchCount: state.dismissedMatches.length,
})
}
onNext()
}}
size="large"
color="primary">
<ButtonText>
{context === 'Onboarding' ? (
<Trans>Next</Trans>
) : (
<Trans>Done</Trans>
)}
</ButtonText>
</Button>
</View>
</View>
)
}
function keyExtractor(item: Item) {
switch (item.type) {
case 'contact':
return item.contact.id
case 'match':
return item.match.profile.did
default:
return item.type
}
}
function MatchItem({
profile,
contact,
moderationOpts,
onRemoveSuggestion,
onFollow,
}: {
profile: bsky.profile.AnyProfileView
contact?: Contact
moderationOpts?: ModerationOpts
onRemoveSuggestion: (did: string) => void
onFollow: () => void
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
const {_} = useLingui()
const shadow = useProfileShadow(profile)
const contactName = useMemo(() => {
if (!contact) return null
const name = contact.firstName ?? contact.lastName ?? contact.name
if (name) return _(msg`Your contact ${name}`)
const phone =
contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0]
if (phone?.number) return phone.number
return null
}, [contact, _])
if (!moderationOpts) return null
return (
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={48}
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
textStyle={[a.leading_tight]}
/>
<ProfileCard.Handle
profile={profile}
textStyle={[contactName && a.text_xs]}
/>
{contactName && (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]}
numberOfLines={1}>
{contactName}
</Text>
)}
</View>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FindContacts"
onFollow={onFollow}
/>
{!shadow.viewer?.following && (
<Button
color="secondary"
variant="ghost"
label={_(msg`Remove suggestion`)}
onPress={() => onRemoveSuggestion(profile.did)}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
hitSlop={8}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</ProfileCard.Header>
</View>
)
}
function ContactItem({
contact,
context,
}: {
contact: Contact
context: 'Onboarding' | 'Standalone'
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const name = contact.firstName ?? contact.lastName ?? contact.name
const phone =
contact.phoneNumbers?.find(phone => phone.isPrimary) ??
contact.phoneNumbers?.[0]
const phoneNumber = phone?.number
return (
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
{contact.image ? (
<UserAvatar size={40} avatar={contact.image.uri} type="user" />
) : (
<View
style={[
{width: 40, height: 40},
a.rounded_full,
a.justify_center,
a.align_center,
t.atoms.bg_contrast_400,
]}>
<Text
style={[
a.text_lg,
a.font_semi_bold,
{color: t.palette.contrast_0},
]}>
{name?.[0]?.toLocaleUpperCase()}
</Text>
</View>
)}
<Text
style={[
a.flex_1,
a.text_md,
a.font_medium,
!name && [t.atoms.text_contrast_medium, a.italic],
]}
numberOfLines={2}>
{name ?? <Trans>No name</Trans>}
</Text>
{phoneNumber && currentAccount && (
<Button
label={_(msg`Invite ${name} to join Bluesky`)}
color="secondary"
size="small"
onPress={async () => {
logger.metric('contacts:matches:invite', {
entryPoint: context,
})
try {
await SMS.sendSMSAsync(
[phoneNumber],
_(
msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`,
),
)
} catch (err) {
Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'})
logger.error('Could not launch SMS', {safeMessage: err})
}
}}>
<ButtonText>
<Trans>Invite</Trans>
</ButtonText>
</Button>
)}
</ProfileCard.Header>
</View>
)
}
function Header({
titleText,
largeTitle,
subtitleText,
children,
hasContentAbove,
}: {
titleText: React.ReactNode
largeTitle?: boolean
subtitleText?: React.ReactNode
children?: React.ReactNode
hasContentAbove?: boolean
}) {
const gutter = useGutters([0, 'wide'])
const t = useTheme()
return (
<View
style={[
gutter,
a.pb_md,
a.gap_sm,
hasContentAbove
? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low]
: a.pt_md,
]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}>
{titleText}
</Text>
{children}
</View>
{subtitleText && (
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
{subtitleText}
</Text>
)}
</View>
)
}
function SearchEmptyState({query}: {query: string}) {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.flex_col,
a.align_center,
a.justify_center,
a.gap_lg,
a.pt_5xl,
a.px_5xl,
]}>
<SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} />
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
a.text_center,
]}>
<Trans>No contacts with the name {query} found</Trans>
</Text>
</View>
)
}
function TotallyEmptyState() {
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.flex_col,
a.align_center,
a.justify_center,
a.gap_lg,
{paddingTop: 140},
a.px_5xl,
]}>
<PersonXIcon width={64} style={[t.atoms.text_contrast_low]} />
<Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}>
<Trans>No contacts found</Trans>
</Text>
</View>
)
}
+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()
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
export function GifSelectDialog({
@@ -25,7 +25,7 @@ import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
@@ -0,0 +1,116 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Text} from '#/components/Typography'
import {navigate} from '#/Navigation'
export function FindContactsAnnouncement() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Introducing finding friends via contacts`)}
style={[web({maxWidth: 440})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View style={[a.align_center, a.pt_3xl]}>
<LinearGradient
colors={[t.palette.primary_200, t.atoms.bg.backgroundColor]}
locations={[0, 1]}
start={{x: 0, y: 0}}
end={{x: 0, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View style={[a.w_full, a.pt_sm, a.px_5xl, a.pb_4xl]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/find_friends_illustration.webp')}
style={[a.w_full, {aspectRatio: 1278 / 661}]}
alt={_(
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
)}
/>
</View>
</View>
<View style={[a.align_center, a.px_xl, a.gap_5xl]}>
<View style={[a.gap_sm, a.align_center]}>
<Text
style={[
a.text_4xl,
a.leading_tight,
a.font_bold,
a.text_center,
{
fontSize: isWeb ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Find your friends</Trans>
</Text>
<Text
style={[
a.text_md,
t.atoms.text_contrast_medium,
a.leading_snug,
a.text_center,
{maxWidth: 340},
]}>
<Trans>
Bluesky is more fun with friends! Import your contacts to see
whos already here.
</Trans>
</Text>
</View>
<Button
label={_(msg`Import Contacts`)}
size="large"
color="primary"
onPress={() => {
logger.metric('contacts:nux:ctaPressed', {})
control.close(() => {
navigate('FindContactsFlow')
})
}}
style={[a.w_full]}>
<ButtonText>
<Trans>Import Contacts</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+26 -16
View File
@@ -1,8 +1,16 @@
import React from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {type AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {STALE} from '#/state/queries'
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
import {
@@ -12,11 +20,11 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
/*
* NUXs
*/
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {FindContactsAnnouncement} from './FindContactsAnnouncement'
import {isExistingUserAsOf} from './utils'
type Context = {
@@ -34,24 +42,24 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
id: Nux.BookmarksAnnouncement,
id: Nux.FindContactsAnnouncement,
enabled: ({currentProfile}) => {
return isExistingUserAsOf(
'2025-09-08T00:00:00.000Z',
currentProfile.createdAt,
return (
isNative &&
isExistingUserAsOf('2025-12-16T00:00:00.000Z', currentProfile.createdAt)
)
},
},
]
const Context = React.createContext<Context>({
const Context = createContext<Context>({
activeNux: undefined,
dismissActiveNux: () => {},
})
Context.displayName = 'NuxDialogContext'
export function useNuxDialogContext() {
return React.useContext(Context)
return useContext(Context)
}
export function NuxDialogs() {
@@ -92,19 +100,19 @@ function Inner({
}) {
const gate = useGate()
const {nuxs} = useNuxs()
const [snoozed, setSnoozed] = React.useState(() => {
const [snoozed, setSnoozed] = useState(() => {
return isSnoozed()
})
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
const [activeNux, setActiveNux] = useState<Nux | undefined>()
const {mutateAsync: saveNux} = useSaveNux()
const {mutate: resetNuxs} = useResetNuxs()
const snoozeNuxDialog = React.useCallback(() => {
const snoozeNuxDialog = useCallback(() => {
snooze()
setSnoozed(true)
}, [setSnoozed])
const dismissActiveNux = React.useCallback(() => {
const dismissActiveNux = useCallback(() => {
if (!activeNux) return
setActiveNux(undefined)
}, [activeNux, setActiveNux])
@@ -118,7 +126,7 @@ function Inner({
}
}
React.useEffect(() => {
useEffect(() => {
if (snoozed) return // comment this out to test
if (!nuxs) return
@@ -170,7 +178,7 @@ function Inner({
preferences,
])
const ctx = React.useMemo(() => {
const ctx = useMemo(() => {
return {
activeNux,
dismissActiveNux,
@@ -180,7 +188,9 @@ function Inner({
return (
<Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />}
{activeNux === Nux.FindContactsAnnouncement && (
<FindContactsAnnouncement />
)}
</Context.Provider>
)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
+14 -8
View File
@@ -141,17 +141,28 @@ export function useSharedInputStyles() {
}, [t])
}
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
export type InputProps = Omit<
TextInputProps,
'value' | 'onChangeText' | 'placeholder'
> & {
label: string
/**
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
*
* See https://github.com/facebook/react-native-website/pull/4247
*
* Note: This guidance no longer applies once we migrate to the New Architecture!
*/
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput>
/**
* Note: this currently falls back to the label if not specified. However,
* most new designs have no placeholder. We should eventually remove this fallback
* behaviour, but for now just pass `null` if you want no placeholder -sfn
*/
placeholder?: string | null | undefined
}
export function createInput(Component: typeof TextInput) {
@@ -255,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
ctx.onBlur()
onBlur?.(e)
}}
placeholder={placeholder || label}
placeholder={placeholder === null ? undefined : placeholder || label}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
style={flattened}
@@ -292,12 +303,7 @@ export function LabelText({
return (
<Text
nativeID={nativeID}
style={[
a.text_sm,
a.font_semi_bold,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}>
{children}
</Text>
)
@@ -4,3 +4,9 @@ export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
createSinglePathSVG({
path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z',
})
export const ArrowRotateClockwise_Stroke2_Corner0_Rounded = createSinglePathSVG(
{
path: 'M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21.561.344 1.082.756 1.58 1.218V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z',
},
)
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Contacts_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513.445.29.683.647.806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12c.123-.29.361-.647.806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z',
})
export const Contacts_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z',
})
+14
View File
@@ -1,6 +1,20 @@
import {createSinglePathSVG} from './TEMPLATE'
export const MagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
})
export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded =
createSinglePathSVG({
path: 'M5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm6-8a8 8 0 1 0 4.906 14.32l3.387 3.387a1 1 0 0 0 1.414-1.414l-3.387-3.387A8 8 0 0 0 11 3Zm4 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
})
export const MagnifyingGlassX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z',
})
export const MagnifyingGlassX_Stroke2_Corner0_Rounded_Large =
createSinglePathSVG({
viewBox: '0 0 64 64',
path: 'M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z',
})
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
})
+5
View File
@@ -16,6 +16,11 @@ export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
})
export const PersonX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({
viewBox: '0 0 64 64',
path: 'M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z',
})
export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
})
@@ -11,7 +11,7 @@ import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
import {Divider} from '#/components/Divider'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotate'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -18,7 +18,7 @@ import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
import {
Check_Stroke2_Corner0_Rounded as CheckThin,
CheckThick_Stroke2_Corner0_Rounded as Check,
+70 -50
View File
@@ -1,3 +1,14 @@
import {type CountryCode as LibPhoneNumberJsCountryCode} from 'libphonenumber-js'
// Exclude Ascension Island and Tristan da Cunha - merged into `SH` in 2009
export type CountryCode = Exclude<LibPhoneNumberJsCountryCode, 'AC' | 'TA'>
/**
* Note: data is from Wikipedia, but some have been removed to match `libphonenumber-js`
* Mostly tiny British overseas territories + Antarctica, all of which
* share codes with a larger country. If you've one of the 10 people from these
* places, you probably know what to do.
*/
export const INTERNATIONAL_TELEPHONE_CODES = {
AD: {
code: '+376',
@@ -34,11 +45,13 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇦🇴',
svgFlag: require('../../assets/icons/flags/AO.svg'),
},
AQ: {
code: '+672',
unicodeFlag: '🇦🇶',
svgFlag: require('../../assets/icons/flags/AQ.svg'),
},
// sorry penguins :(
// same as Norfolk Island
// AQ: {
// code: '+672',
// unicodeFlag: '🇦🇶',
// svgFlag: require('../../assets/icons/flags/AQ.svg'),
// },
AR: {
code: '+54',
unicodeFlag: '🇦🇷',
@@ -154,11 +167,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇧🇹',
svgFlag: require('../../assets/icons/flags/BT.svg'),
},
BV: {
code: '+47',
unicodeFlag: '🇧🇻',
svgFlag: require('../../assets/icons/flags/BV.svg'),
},
// same as Norway
// BV: {
// code: '+47',
// unicodeFlag: '🇧🇻',
// svgFlag: require('../../assets/icons/flags/BV.svg'),
// },
BW: {
code: '+267',
unicodeFlag: '🇧🇼',
@@ -379,11 +393,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇬🇷',
svgFlag: require('../../assets/icons/flags/GR.svg'),
},
GS: {
code: '+500',
unicodeFlag: '🇬🇸',
svgFlag: require('../../assets/icons/flags/GS.svg'),
},
// same as Falkland Islands
// GS: {
// code: '+500',
// unicodeFlag: '🇬🇸',
// svgFlag: require('../../assets/icons/flags/GS.svg'),
// },
GT: {
code: '+502',
unicodeFlag: '🇬🇹',
@@ -779,11 +794,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇵🇲',
svgFlag: require('../../assets/icons/flags/PM.svg'),
},
PN: {
code: '+64',
unicodeFlag: '🇵🇳',
svgFlag: require('../../assets/icons/flags/PN.svg'),
},
// same as New Zealand
// PN: {
// code: '+64',
// unicodeFlag: '🇵🇳',
// svgFlag: require('../../assets/icons/flags/PN.svg'),
// },
PR: {
code: '+1',
unicodeFlag: '🇵🇷',
@@ -1199,11 +1215,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇫🇴',
svgFlag: require('../../assets/icons/flags/FO.svg'),
},
HM: {
code: '+672',
unicodeFlag: '🇭🇲',
svgFlag: require('../../assets/icons/flags/HM.svg'),
},
// same as Norfolk Island
// HM: {
// code: '+672',
// unicodeFlag: '🇭🇲',
// svgFlag: require('../../assets/icons/flags/HM.svg'),
// },
KM: {
code: '+269',
unicodeFlag: '🇰🇲',
@@ -1229,16 +1246,18 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇹🇨',
svgFlag: require('../../assets/icons/flags/TC.svg'),
},
TF: {
code: '+672',
unicodeFlag: '🇹🇫',
svgFlag: require('../../assets/icons/flags/TF.svg'),
},
UM: {
code: '+1',
unicodeFlag: '🇺🇲',
svgFlag: require('../../assets/icons/flags/UM.svg'),
},
// same as Norfolk Island
// TF: {
// code: '+672',
// unicodeFlag: '🇹🇫',
// svgFlag: require('../../assets/icons/flags/TF.svg'),
// },
// same as US mainland
// UM: {
// code: '+1',
// unicodeFlag: '🇺🇲',
// svgFlag: require('../../assets/icons/flags/UM.svg'),
// },
VA: {
code: '+39',
unicodeFlag: '🇻🇦',
@@ -1249,25 +1268,26 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇽🇰',
svgFlag: require('../../assets/icons/flags/XK.svg'),
},
}
} satisfies Record<
CountryCode,
{
code: string
unicodeFlag: string
svgFlag: any
}
>
const DEFAULT_PHONE_COUNTRY = 'US'
const DEFAULT_PHONE_COUNTRY = 'US' as const
export function getDefaultCountry(location?: {countryCode?: string}) {
export function getDefaultCountry(location?: {
countryCode?: string
}): CountryCode {
const locationCountryCode = location?.countryCode?.toUpperCase()
if (
location?.countryCode &&
location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES
locationCountryCode &&
locationCountryCode in INTERNATIONAL_TELEPHONE_CODES
) {
return location.countryCode.toUpperCase()
return locationCountryCode as CountryCode
}
return DEFAULT_PHONE_COUNTRY
}
export function getPhoneCodeFromCountryCode(countryCode: string) {
const country =
INTERNATIONAL_TELEPHONE_CODES[
countryCode.toUpperCase() as keyof typeof INTERNATIONAL_TELEPHONE_CODES
]
if (!country) throw new Error(`Country ${countryCode} not found`)
return country.code
}
+2
View File
@@ -67,6 +67,7 @@ export type CommonNavigatorParams = {
InterestsSettings: undefined
AboutSettings: undefined
AppIconSettings: undefined
FindContactsSettings: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
@@ -87,6 +88,7 @@ export type CommonNavigatorParams = {
StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext
Bookmarks: undefined
FindContactsFlow: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'alt_share_icon'
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'disable_onboarding_find_contacts'
| 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread'
| 'old_postonboarding'
+89
View File
@@ -309,6 +309,7 @@ export type MetricEvents = {
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
}
'profile:followers:view': {
contextProfileDid: string
@@ -396,6 +397,7 @@ export type MetricEvents = {
| 'ImmersiveVideo'
| 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts'
| 'FindContacts'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -625,4 +627,91 @@ export type MetricEvents = {
'blockedGeoOverlay:shown': {}
'geo:debug': {}
/*
* Find Contacts stuff
*/
// user presses the button on the new feature NUX
'contacts:nux:ctaPressed': {}
// user presses the banner NUX
'contacts:nux:bannerPressed': {}
// user dismisses the banner
'contacts:nux:bannerDismissed': {}
// user lands on the contacts step
'onboarding:contacts:presented': {}
// user pressed "Import Contacts" button to begin flow
'onboarding:contacts:begin': {}
// skips the step entirely
'onboarding:contacts:skipPressed': {}
// user shared their contacts
'onboarding:contacts:contactsShared': {}
// user leaves the matches page
'onboarding:contacts:nextPressed': {
matchCount: number
followCount: number
dismissedMatchCount: number
}
// user entered a number
'contacts:phone:phoneEntered': {
entryPoint: 'Onboarding' | 'Standalone'
}
// user entered the correct one-time-code
'contacts:phone:phoneVerified': {
entryPoint: 'Onboarding' | 'Standalone'
}
// user responded to the contacts permission prompt
'contacts:permission:request': {
status: 'granted' | 'denied'
accessLevelIOS?: 'all' | 'limited' | 'none'
}
// contacts were successfully imported and matched
'contacts:import:success': {
contactCount: number
matchCount: number
entryPoint: 'Onboarding' | 'Standalone'
}
// contacts import failed
'contacts:import:failure': {
reason: 'noValidNumbers' | 'networkError' | 'unknown'
entryPoint: 'Onboarding' | 'Standalone'
}
// user followed a single match
'contacts:matches:follow': {
entryPoint: 'Onboarding' | 'Standalone'
}
// user pressed "Follow All" on matches
'contacts:matches:followAll': {
followCount: number
entryPoint: 'Onboarding' | 'Standalone'
}
// user dismissed a match
'contacts:matches:dismiss': {
entryPoint: 'Onboarding' | 'Standalone'
}
// user pressed invite to send an SMS to a non-match
'contacts:matches:invite': {
entryPoint: 'Onboarding' | 'Standalone'
}
// user opened the Find Contacts settings screen
'contacts:settings:presented': {
hasPreviouslySynced: boolean
matchCount?: number
}
// user followed a single match from settings
'contacts:settings:follow': {}
// user pressed "Follow All" from settings
'contacts:settings:followAll': {
followCount: number
}
// user dismissed a match from settings
'contacts:settings:dismiss': {}
// user re-entered the flow via the resync button
'contacts:settings:resync': {
daysSinceLastSync: number
}
// user pressed the remove all data button
'contacts:settings:removeData': {}
}
+2
View File
@@ -69,6 +69,7 @@ export const router = new Router<AllNavigatableRoutes>({
'/settings/notifications/reposts-on-reposts',
ActivityNotificationSettings: '/settings/notifications/activity',
MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous',
FindContactsSettings: '/settings/find-contacts',
// support
Support: '/support',
PrivacyPolicy: '/support/privacy',
@@ -91,4 +92,5 @@ export const router = new Router<AllNavigatableRoutes>({
StarterPackWizard: '/starter-pack/create',
VideoFeed: '/video-feed',
Bookmarks: '/saved',
FindContactsFlow: '/find-contacts',
})
+77
View File
@@ -0,0 +1,77 @@
import {useCallback, useLayoutEffect, useState} from 'react'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {usePreventRemove} from '@react-navigation/native'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {useSetMinimalShellMode} from '#/state/shell'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {FindContactsFlow} from '#/components/contacts/FindContactsFlow'
import {useFindContactsFlowState} from '#/components/contacts/state'
import * as Layout from '#/components/Layout'
import {ScreenTransition} from '#/components/ScreenTransition'
type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsFlow'>
export function FindContactsFlowScreen({navigation}: Props) {
const {_} = useLingui()
const [state, dispatch] = useFindContactsFlowState()
const [transitionDirection, setTransitionDirection] = useState<
'Forward' | 'Backward'
>('Forward')
const overrideGoBack = state.step === '2: verify number'
usePreventRemove(overrideGoBack, () => {
setTransitionDirection('Backward')
dispatch({type: 'BACK'})
setTimeout(() => {
setTransitionDirection('Forward')
})
})
useEnableKeyboardControllerScreen(true)
const setMinimalShellMode = useSetMinimalShellMode()
const effect = useCallback(() => {
setMinimalShellMode(true)
return () => setMinimalShellMode(false)
}, [setMinimalShellMode])
useLayoutEffect(effect)
return (
<Layout.Screen>
{isNative ? (
<LayoutAnimationConfig skipEntering skipExiting>
<ScreenTransition key={state.step} direction={transitionDirection}>
<FindContactsFlow
state={state}
dispatch={dispatch}
onCancel={() =>
navigation.canGoBack()
? navigation.goBack()
: navigation.navigate('FindContactsFlow', undefined, {
pop: true,
})
}
context="Standalone"
/>
</ScreenTransition>
</LayoutAnimationConfig>
) : (
<ErrorScreen
title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)}
showHeader
/>
)}
</Layout.Screen>
)
}
+1 -1
View File
@@ -29,7 +29,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {type DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+1 -1
View File
@@ -37,7 +37,7 @@ import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+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>
)
}
@@ -0,0 +1,60 @@
import {useCallback, useState} from 'react'
import {LayoutAnimationConfig} from 'react-native-reanimated'
import {SafeAreaView} from 'react-native-safe-area-context'
import {logger} from '#/logger'
import {FindContactsFlow} from '#/components/contacts/FindContactsFlow'
import {type Action, type State} from '#/components/contacts/state'
import {ScreenTransition} from '#/components/ScreenTransition'
import {useOnboardingInternalState} from '../state'
export function StepFindContacts({
flowState,
flowDispatch,
}: {
flowState: State
flowDispatch: React.ActionDispatch<[Action]>
}) {
const {dispatch} = useOnboardingInternalState()
const [transitionDirection, setTransitionDirection] = useState<
'Forward' | 'Backward'
>('Forward')
const isFinalStep = flowState.step === '4: view matches'
const onSkip = useCallback(() => {
if (!isFinalStep) {
logger.metric('onboarding:contacts:skipPressed', {})
}
dispatch({type: 'next'})
}, [dispatch, isFinalStep])
const canGoBack = flowState.step === '2: verify number'
const onBack = useCallback(() => {
if (canGoBack) {
setTransitionDirection('Backward')
flowDispatch({type: 'BACK'})
setTimeout(() => {
setTransitionDirection('Forward')
})
} else {
dispatch({type: 'prev'})
}
}, [dispatch, flowDispatch, canGoBack])
return (
<SafeAreaView edges={['left', 'top', 'right']}>
<LayoutAnimationConfig skipEntering skipExiting>
<ScreenTransition key={flowState.step} direction={transitionDirection}>
<FindContactsFlow
context="Onboarding"
state={flowState}
dispatch={flowDispatch}
onCancel={onSkip}
onBack={onBack}
/>
</ScreenTransition>
</LayoutAnimationConfig>
</SafeAreaView>
)
}
@@ -0,0 +1,3 @@
export function StepFindContacts() {
throw new Error('StepFindContacts is not available on web')
}
@@ -0,0 +1,88 @@
import {View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} from '@tanstack/react-query'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {InlineLinkText} from '#/components/Link'
import {
OnboardingControls,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '../Layout'
import {useOnboardingInternalState} from '../state'
export function StepFindContactsIntro() {
const {_} = useLingui()
const {dispatch} = useOnboardingInternalState()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<View style={[a.w_full, a.gap_sm]}>
<OnboardingPosition />
<ContactsHeroImage />
<OnboardingTitleText style={[a.mt_sm]}>
<Trans>Bluesky is more fun with friends</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>
Find your friends on Bluesky by verifying your phone number and
matching with your contacts. We protect your information and you
control what happens next.{' '}
<InlineLinkText
to="#"
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_md, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</OnboardingDescriptionText>
{!isAvailable && isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)}
<OnboardingControls.Portal>
<View style={[a.gap_md]}>
<Button
onPress={() => dispatch({type: 'next'})}
label={_(msg`Import contacts`)}
size="large"
color="primary"
disabled={!isAvailable}>
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
</Button>
<Button
onPress={() => dispatch({type: 'skip-contacts'})}
label={_(msg`Skip`)}
size="large"
color="secondary">
<ButtonText>
<Trans>Skip</Trans>
</ButtonText>
</Button>
</View>
</OnboardingControls.Portal>
</View>
)
}
@@ -0,0 +1,3 @@
export function StepFindContactsIntro() {
throw new Error('StepFindContactsIntro is not available on web')
}
+8 -106
View File
@@ -1,4 +1,4 @@
import {useCallback, useContext, useState} from 'react'
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
@@ -35,28 +35,23 @@ import {
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
import {
DescriptionText,
OnboardingControls,
OnboardingHeaderSlot,
TitleText,
} from '#/screens/Onboarding/Layout'
import {Context, type OnboardingState} from '#/screens/Onboarding/state'
import {
type OnboardingState,
useOnboardingInternalState,
} from '#/screens/Onboarding/state'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() {
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = useState(false)
const queryClient = useQueryClient()
@@ -247,18 +242,12 @@ export function StepFinished() {
gate,
])
return state.experiments?.onboarding_value_prop ? (
return (
<ValueProposition
finishOnboarding={finishOnboarding}
saving={saving}
state={state}
/>
) : (
<LegacyFinalStep
finishOnboarding={finishOnboarding}
saving={saving}
state={state}
/>
)
}
@@ -359,90 +348,3 @@ function ValueProposition({
</>
)
}
function LegacyFinalStep({
finishOnboarding,
saving,
state,
}: {
finishOnboarding: () => void
saving: boolean
state: OnboardingState
}) {
const t = useTheme()
const {_} = useLingui()
return (
<View style={[a.align_start]}>
<IconCircle icon={Check} style={[a.mb_2xl]} />
<TitleText>
<Trans>You're ready to go!</Trans>
</TitleText>
<DescriptionText>
<Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
</DescriptionText>
<View style={[a.pt_5xl, a.gap_3xl]}>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Public</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Open</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Never lose access to your followers or data.</Trans>
</Text>
</View>
</View>
<View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
<IconCircle
icon={Trending}
size="lg"
style={{width: 48, height: 48}}
/>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_semi_bold, a.text_lg]}>
<Trans>Flexible</Trans>
</Text>
<Text
style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}>
<Trans>Choose the algorithms that power your custom feeds.</Trans>
</Text>
</View>
</View>
</View>
<OnboardingControls.Portal>
<Button
testID="onboardingFinish"
disabled={saving}
key={state.activeStep} // remove focus state on nav
color="primary"
size="large"
label={_(msg`Complete onboarding and start using your account`)}
onPress={finishOnboarding}>
<ButtonText>
{saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
</ButtonText>
{saving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</OnboardingControls.Portal>
</View>
)
}
+13 -16
View File
@@ -8,25 +8,23 @@ import {logEvent} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {
DescriptionText,
OnboardingControls,
TitleText,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
export function StepInterests() {
const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = React.useState(false)
const [selectedInterests, setSelectedInterests] = React.useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i),
@@ -53,17 +51,16 @@ export function StepInterests() {
}, [selectedInterests, setSaving, dispatch])
return (
<View style={[a.align_start]} testID="onboardingInterests">
<IconCircle icon={Hashtag} style={[a.mb_2xl]} />
<TitleText>
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans>What are your interests?</Trans>
</TitleText>
<DescriptionText>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>We'll use this to help customize your experience.</Trans>
</DescriptionText>
</OnboardingDescriptionText>
<View style={[a.w_full, a.pt_2xl]}>
<View style={[a.w_full, a.pt_lg]}>
<Toggle.Group
values={selectedInterests}
onChange={setSelectedInterests}
@@ -93,7 +90,7 @@ export function StepInterests() {
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} />
{saving && <ButtonIcon icon={Loader} />}
</Button>
</OnboardingControls.Portal>
</View>
+21 -25
View File
@@ -19,11 +19,12 @@ import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {
DescriptionText,
OnboardingControls,
TitleText,
OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
@@ -32,13 +33,10 @@ import {
type PlaceholderCanvasRef,
} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
import {IconCircle} from '#/components/IconCircle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
import {Text} from '#/components/Typography'
import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types'
@@ -78,7 +76,7 @@ export function StepProfile() {
const creatorControl = Dialog.useDialogControl()
const [error, setError] = React.useState('')
const {state, dispatch} = React.useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [avatar, setAvatar] = React.useState<Avatar>({
image: state.profileStepResults?.image,
placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at,
@@ -237,19 +235,21 @@ export function StepProfile() {
return (
<AvatarContext.Provider value={value}>
<View style={[a.align_start, t.atoms.bg, a.justify_between]}>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
<TitleText>
<Trans>Give your profile a face</Trans>
</TitleText>
<DescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or creating
an avatar.
</Trans>
</DescriptionText>
<View style={[a.align_start]}>
<View style={[a.gap_sm]}>
<OnboardingPosition />
<OnboardingTitleText>
<Trans>Give your profile a face</Trans>
</OnboardingTitleText>
<OnboardingDescriptionText>
<Trans>
Help people know you're not a bot by uploading a picture or
creating an avatar.
</Trans>
</OnboardingDescriptionText>
</View>
<View
style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 40}]}>
style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 60}]}>
<AvatarCircle
openLibrary={openLibrary}
openCreator={creatorControl.open}
@@ -279,7 +279,6 @@ export function StepProfile() {
<View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
<Button
testID="onboardingContinue"
variant="solid"
color="primary"
size="large"
label={_(msg`Continue to next step`)}
@@ -287,12 +286,10 @@ export function StepProfile() {
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
<Button
testID="onboardingAvatarCreator"
variant="ghost"
color="primary"
color="primary_subtle"
size="large"
label={_(msg`Open avatar creator`)}
onPress={onSecondaryPress}>
@@ -335,7 +332,6 @@ export function StepProfile() {
</View>
<View style={[a.pt_4xl]}>
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Done`)}
@@ -1,11 +1,4 @@
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
@@ -22,19 +15,22 @@ import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../util'
@@ -47,7 +43,7 @@ export function StepSuggestedAccounts() {
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
// keeping track of who was followed via the follow all button
@@ -153,18 +149,21 @@ export function StepSuggestedAccounts() {
)
return (
<View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}>
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
</Trans>
</Text>
</OnboardingTitleText>
<View
style={[
a.overflow_hidden,
a.mt_lg,
isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1},
a.mt_sm,
isWeb
? [a.max_w_full, web({minHeight: '100vh'})]
: {marginHorizontal: tokens.space.xl * -1},
a.flex_1,
a.justify_start,
]}>
@@ -1,18 +1,20 @@
import {useContext} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {OnboardingControls} from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state'
import {
OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {StarterPackCard} from './StarterPackCard'
export function StepSuggestedStarterpacks() {
@@ -20,7 +22,7 @@ export function StepSuggestedStarterpacks() {
const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context)
const {state, dispatch} = useOnboardingInternalState()
const {
data: suggestedStarterPacks,
@@ -34,26 +36,26 @@ export function StepSuggestedStarterpacks() {
})
return (
<View style={[a.align_start]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}>
<Trans comment="Accounts suggested to the user for them to follow">
Suggested for you
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Starter packs suggested to the user for them to follow">
Find people to follow
</Trans>
</Text>
</OnboardingTitleText>
<View
style={[
a.overflow_hidden,
a.mt_lg,
a.flex_1,
a.justify_start,
a.w_full,
a.mt_sm,
]}>
{isLoading || !moderationOpts ? (
<View
style={[
a.flex_1,
a.mt_md,
a.align_center,
a.justify_center,
{minHeight: 400},
@@ -69,7 +71,7 @@ export function StepSuggestedStarterpacks() {
</Admonition>
</View>
) : (
<View style={[a.flex_1, a.mt_md]}>
<View style={[a.flex_1]}>
{suggestedStarterPacks?.starterPacks.map(starterPack => (
<View style={[a.pb_lg]} key={starterPack.uri}>
<StarterPackCard view={starterPack} />
+61 -60
View File
@@ -1,28 +1,38 @@
import {useMemo, useReducer} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {View} from 'react-native'
import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences'
import {
Layout,
OnboardingControls,
OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout'
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
import {
Context,
createInitialOnboardingState,
reducer,
} from '#/screens/Onboarding/state'
import {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepProfile} from '#/screens/Onboarding/StepProfile'
import {atoms as a, useTheme} from '#/alf'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
import {useFindContactsFlowState} from '#/components/contacts/state'
import {Portal} from '#/components/Portal'
import {ScreenTransition} from '#/components/ScreenTransition'
import {ENV} from '#/env'
import {StepFindContacts} from './StepFindContacts'
import {StepFindContactsIntro} from './StepFindContactsIntro'
import {StepSuggestedAccounts} from './StepSuggestedAccounts'
import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks'
export function Onboarding() {
const {_} = useLingui()
const gate = useGate()
const t = useTheme()
const {contentLanguages} = useLanguagePrefs()
const probablySpeaksEnglish = useMemo(() => {
@@ -36,70 +46,61 @@ export function Onboarding() {
probablySpeaksEnglish &&
gate('onboarding_suggested_starterpacks')
const [state, dispatch] = useReducer(reducer, {
...initialState,
totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0),
experiments: {
onboarding_suggested_accounts: true,
onboarding_value_prop: true,
onboarding_suggested_starterpacks: showSuggestedStarterpacks,
},
})
const findContactsEnabled =
useIsFindContactsFeatureEnabledBasedOnGeolocation()
const showFindContacts =
isNative && findContactsEnabled && !gate('disable_onboarding_find_contacts')
const interestsDisplayNames = useMemo(() => {
return {
news: _(msg`News`),
journalism: _(msg`Journalism`),
nature: _(msg`Nature`),
art: _(msg`Art`),
comics: _(msg`Comics`),
writers: _(msg`Writers`),
culture: _(msg`Culture`),
sports: _(msg`Sports`),
pets: _(msg`Pets`),
animals: _(msg`Animals`),
books: _(msg`Books`),
education: _(msg`Education`),
climate: _(msg`Climate`),
science: _(msg`Science`),
politics: _(msg`Politics`),
fitness: _(msg`Fitness`),
tech: _(msg`Tech`),
dev: _(msg`Software Dev`),
comedy: _(msg`Comedy`),
gaming: _(msg`Video Games`),
food: _(msg`Food`),
cooking: _(msg`Cooking`),
}
}, [_])
const [state, dispatch] = useReducer(
reducer,
{
starterPacksStepEnabled: showSuggestedStarterpacks,
findContactsStepEnabled: showFindContacts,
},
createInitialOnboardingState,
)
const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
useEnableKeyboardControllerScreen(true)
return (
<Portal>
<OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider>
<Context.Provider
value={useMemo(
() => ({state, dispatch, interestsDisplayNames}),
[state, dispatch, interestsDisplayNames],
)}>
<Layout>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
<OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider>
<Context.Provider
value={useMemo(() => ({state, dispatch}), [state, dispatch])}>
<ScreenTransition
key={state.activeStep}
direction={state.stepTransitionDirection}>
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && (
<StepSuggestedAccounts />
direction={state.stepTransitionDirection}
style={a.flex_1}>
{/* FindContactsFlow cannot be nested in Layout */}
{state.activeStep === 'find-contacts' ? (
<StepFindContacts
flowState={contactsFlowState}
flowDispatch={contactsFlowDispatch}
/>
) : (
<Layout>
{state.activeStep === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && (
<StepSuggestedAccounts />
)}
{state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks />
)}
{state.activeStep === 'find-contacts-intro' && (
<StepFindContactsIntro />
)}
{state.activeStep === 'finished' && <StepFinished />}
</Layout>
)}
{state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks />
)}
{state.activeStep === 'finished' && <StepFinished />}
</ScreenTransition>
</Layout>
</Context.Provider>
</OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider>
</Context.Provider>
</OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider>
</View>
</Portal>
)
}
+126 -54
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {createContext, useContext, useMemo} from 'react'
import {logger} from '#/logger'
import {
@@ -6,16 +6,18 @@ import {
type Emoji,
} from '#/screens/Onboarding/StepProfile/types'
type OnboardingScreen =
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'find-contacts-intro'
| 'find-contacts'
| 'finished'
export type OnboardingState = {
hasPrev: boolean
totalSteps: number
activeStep:
| 'profile'
| 'interests'
| 'suggested-accounts'
| 'suggested-starterpacks'
| 'finished'
activeStepIndex: number
screens: Record<OnboardingScreen, boolean>
activeStep: OnboardingScreen
stepTransitionDirection: 'Forward' | 'Backward'
interestsStepResults: {
@@ -37,12 +39,6 @@ export type OnboardingState = {
backgroundColor: AvatarColor
}
}
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
}
export type OnboardingAction =
@@ -52,6 +48,9 @@ export type OnboardingAction =
| {
type: 'prev'
}
| {
type: 'skip-contacts'
}
| {
type: 'finish'
}
@@ -73,31 +72,45 @@ export type OnboardingAction =
| undefined
}
export const initialState: OnboardingState = {
hasPrev: false,
totalSteps: 3,
activeStep: 'profile',
activeStepIndex: 1,
stepTransitionDirection: 'Forward',
export function createInitialOnboardingState(
{
starterPacksStepEnabled,
findContactsStepEnabled,
}: {
starterPacksStepEnabled: boolean
findContactsStepEnabled: boolean
} = {starterPacksStepEnabled: true, findContactsStepEnabled: false},
): OnboardingState {
const screens: OnboardingState['screens'] = {
profile: true,
interests: true,
'suggested-accounts': true,
'suggested-starterpacks': starterPacksStepEnabled,
'find-contacts-intro': findContactsStepEnabled,
'find-contacts': findContactsStepEnabled,
finished: true,
}
interestsStepResults: {
selectedInterests: [],
},
profileStepResults: {
isCreatedAvatar: false,
image: undefined,
imageUri: '',
imageMime: '',
},
return {
screens,
activeStep: 'profile',
stepTransitionDirection: 'Forward',
interestsStepResults: {
selectedInterests: [],
},
profileStepResults: {
isCreatedAvatar: false,
image: undefined,
imageUri: '',
imageMime: '',
},
}
}
export const Context = React.createContext<{
export const Context = createContext<{
state: OnboardingState
dispatch: React.Dispatch<OnboardingAction>
}>({
state: {...initialState},
dispatch: () => {},
})
} | null>(null)
Context.displayName = 'OnboardingContext'
export function reducer(
@@ -106,42 +119,39 @@ export function reducer(
): OnboardingState {
let next = {...s}
const stepOrder: OnboardingState['activeStep'][] = [
'profile',
'interests',
...(s.experiments?.onboarding_suggested_accounts
? (['suggested-accounts'] as const)
: []),
...(s.experiments?.onboarding_suggested_starterpacks
? (['suggested-starterpacks'] as const)
: []),
'finished',
]
const stepOrder = getStepOrder(s)
switch (a.type) {
case 'next': {
// 1-indexed for some reason
const nextIndex = s.activeStepIndex
const nextIndex = stepOrder.indexOf(next.activeStep) + 1
const nextStep = stepOrder[nextIndex]
if (nextStep) {
next.activeStep = nextStep
next.activeStepIndex = nextIndex + 1
}
next.stepTransitionDirection = 'Forward'
break
}
case 'prev': {
const prevIndex = s.activeStepIndex - 2
const prevIndex = stepOrder.indexOf(next.activeStep) - 1
const prevStep = stepOrder[prevIndex]
if (prevStep) {
next.activeStep = prevStep
next.activeStepIndex = prevIndex + 1
}
next.stepTransitionDirection = 'Backward'
break
}
case 'skip-contacts': {
const nextIndex = stepOrder.indexOf('find-contacts') + 1
const nextStep = stepOrder[nextIndex] ?? 'finished'
next.activeStep = nextStep
next.stepTransitionDirection = 'Forward'
break
}
case 'finish': {
next = initialState
next = createInitialOnboardingState({
starterPacksStepEnabled: s.screens['suggested-starterpacks'],
findContactsStepEnabled: s.screens['find-contacts'],
})
break
}
case 'setInterestsStepResults': {
@@ -162,6 +172,15 @@ export function reducer(
}
}
if (a.type === 'next') {
if (next.activeStep === 'find-contacts-intro') {
logger.metric('onboarding:contacts:presented', {})
}
if (next.activeStep === 'find-contacts') {
logger.metric('onboarding:contacts:begin', {})
}
}
const state = {
...next,
hasPrev: next.activeStep !== 'profile',
@@ -170,7 +189,6 @@ export function reducer(
logger.debug(`onboarding`, {
hasPrev: state.hasPrev,
activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests,
},
@@ -183,3 +201,57 @@ export function reducer(
return state
}
function getStepOrder(s: OnboardingState): OnboardingScreen[] {
return [
s.screens.profile && ('profile' as const),
s.screens.interests && ('interests' as const),
s.screens['suggested-accounts'] && ('suggested-accounts' as const),
s.screens['suggested-starterpacks'] && ('suggested-starterpacks' as const),
s.screens['find-contacts-intro'] && ('find-contacts-intro' as const),
s.screens['find-contacts'] && ('find-contacts' as const),
s.screens.finished && ('finished' as const),
].filter(x => !!x)
}
/**
* Note: not to be confused with `useOnboardingState`, which just determines if onboarding is active.
* This hook is for internal state of the onboarding flow (i.e. active step etc).
*
* This adds additional derived state to the onboarding context reducer.
*/
export function useOnboardingInternalState() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error(
'useOnboardingInternalState must be used within OnboardingContext',
)
}
const {state, dispatch} = ctx
return {
state: useMemo(() => {
const stepOrder = getStepOrder(state).filter(
x => x !== 'find-contacts' && x !== 'finished',
) as string[]
const canGoBack = state.activeStep !== stepOrder[0]
return {
...state,
canGoBack,
/**
* Note: for *display* purposes only, do not lean on this
* for navigation purposes! we merge certain steps!
*/
activeStepIndex: stepOrder.indexOf(
state.activeStep === 'find-contacts'
? 'find-contacts-intro'
: state.activeStep,
),
totalSteps: stepOrder.length,
}
}, [state]),
dispatch,
}
}
@@ -7,7 +7,7 @@ import {useCleanError} from '#/lib/hooks/useCleanError'
import {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
@@ -10,7 +10,7 @@ import {atoms as a, native, useTheme, type ViewStyleProp} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {sizes as iconSizes} from '#/components/icons/common'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {Link} from '#/components/Link'
import {Text, type TextProps} from '#/components/Typography'
@@ -0,0 +1,562 @@
import {useCallback, useEffect, useState} from 'react'
import {type ListRenderItemInfo, View} from 'react-native'
import * as Contacts from 'expo-contacts'
import {
type AppBskyContactDefs,
type AppBskyContactGetSyncStatus,
type ModerationOpts,
} from '@atproto/api'
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused} from '@react-navigation/native'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {wait} from '#/lib/async/wait'
import {HITSLOP_10} from '#/lib/constants'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {
updateProfileShadow,
useProfileShadow,
} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
findContactsStatusQueryKey,
optimisticRemoveMatch,
useContactsMatchesQuery,
useContactsSyncStatusQuery,
} from '#/state/queries/find-contacts'
import {useAgent, useSession} from '#/state/session'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {List} from '#/view/com/util/List'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ContactsHeroImage} from '#/components/contacts/components/HeroImage'
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Layout from '#/components/Layout'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../Onboarding/util'
type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'>
export function FindContactsSettingsScreen({}: Props) {
const {_} = useLingui()
const {data, error, refetch} = useContactsSyncStatusQuery()
const isFocused = useIsFocused()
useEffect(() => {
if (data && isFocused) {
logger.metric('contacts:settings:presented', {
hasPreviouslySynced: !!data.syncStatus,
matchCount: data.syncStatus?.matchesCount,
})
}
}, [data, isFocused])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Find Friends</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
{isNative ? (
data ? (
!data.syncStatus ? (
<Intro />
) : (
<SyncStatus info={data.syncStatus} refetchStatus={refetch} />
)
) : error ? (
<ErrorScreen
title={_(msg`Error getting the latest data.`)}
message={cleanError(error)}
onPressTryAgain={refetch}
/>
) : (
<View style={[a.flex_1, a.justify_center, a.align_center]}>
<Loader size="xl" />
</View>
)
) : (
<ErrorScreen
title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)}
/>
)}
</Layout.Screen>
)
}
function Intro() {
const gutter = useGutters(['base'])
const t = useTheme()
const {_} = useLingui()
const {data: isAvailable, isSuccess} = useQuery({
queryKey: ['contacts-available'],
queryFn: async () => await Contacts.isAvailableAsync(),
})
return (
<Layout.Content contentContainerStyle={[gutter, a.gap_lg]}>
<ContactsHeroImage />
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Find your friends on Bluesky by verifying your phone number and
matching with your contacts. We protect your information and you
control what happens next.{' '}
<InlineLinkText
to="#"
label={_(
msg({
message: `Learn more about importing contacts`,
context: `english-only-resource`,
}),
)}
style={[a.text_md, a.leading_snug]}>
<Trans context="english-only-resource">Learn more</Trans>
</InlineLinkText>
</Trans>
</Text>
{isAvailable ? (
<Link
to={{screen: 'FindContactsFlow'}}
label={_(msg`Import contacts`)}
size="large"
color="primary"
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Import contacts</Trans>
</ButtonText>
</Link>
) : (
isSuccess && (
<Admonition type="error">
<Trans>
Contact sync is not available on this device, as the app is unable
to access your contacts.
</Trans>
</Admonition>
)
)}
</Layout.Content>
)
}
function SyncStatus({
info,
refetchStatus,
}: {
info: AppBskyContactDefs.SyncStatus
refetchStatus: () => Promise<any>
}) {
const agent = useAgent()
const queryClient = useQueryClient()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {
data,
isPending,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
refetch: refetchMatches,
} = useContactsMatchesQuery()
const [isPTR, setIsPTR] = useState(false)
const onRefresh = () => {
setIsPTR(true)
Promise.all([refetchStatus(), refetchMatches()]).finally(() => {
setIsPTR(false)
})
}
const {mutate: dismissMatch} = useMutation({
mutationFn: async (did: string) => {
await agent.app.bsky.contact.dismissMatch({subject: did})
},
onMutate: async (did: string) => {
logger.metric('contacts:settings:dismiss', {})
optimisticRemoveMatch(queryClient, did)
},
onError: err => {
refetchMatches()
if (isNetworkError(err)) {
Toast.show(
_(
msg`Could not follow all matches - please check your network connection.`,
),
{type: 'error'},
)
} else {
logger.error('Failed to follow all matches', {safeMessage: err})
Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
const profiles = data?.pages?.flatMap(page => page.matches) ?? []
const numProfiles = profiles.length
const isAnyUnfollowed = profiles.some(profile => !profile.viewer?.following)
const renderItem = useCallback(
({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<MatchItem
profile={item}
isFirst={index === 0}
isLast={index === numProfiles - 1}
moderationOpts={moderationOpts}
dismissMatch={dismissMatch}
/>
)
},
[numProfiles, moderationOpts, dismissMatch],
)
const onEndReached = () => {
if (!hasNextPage || isFetchingNextPage) return
fetchNextPage()
}
return (
<List
data={profiles}
renderItem={renderItem}
ListHeaderComponent={
<StatusHeader
numMatches={info.matchesCount}
isPending={isPending}
isAnyUnfollowed={isAnyUnfollowed}
/>
}
ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />}
onRefresh={onRefresh}
refreshing={isPTR}
onEndReached={onEndReached}
/>
)
}
function MatchItem({
profile,
isFirst,
isLast,
moderationOpts,
dismissMatch,
}: {
profile: bsky.profile.AnyProfileView
isFirst: boolean
isLast: boolean
moderationOpts: ModerationOpts
dismissMatch: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const shadow = useProfileShadow(profile)
return (
<View style={[a.px_xl]}>
<View
style={[
a.p_md,
a.border_t,
a.border_x,
t.atoms.border_contrast_high,
isFirst && [
a.curve_continuous,
{borderTopLeftRadius: tokens.borderRadius.lg},
{borderTopRightRadius: tokens.borderRadius.lg},
],
isLast && [
a.border_b,
a.curve_continuous,
{borderBottomLeftRadius: tokens.borderRadius.lg},
{borderBottomRightRadius: tokens.borderRadius.lg},
a.mb_sm,
],
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FindContacts"
onFollow={() => logger.metric('contacts:settings:follow', {})}
/>
{!shadow.viewer?.following && (
<Button
color="secondary"
variant="ghost"
label={_(msg`Remove suggestion`)}
onPress={() => dismissMatch(profile.did)}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
hitSlop={8}>
<ButtonIcon icon={XIcon} />
</Button>
)}
</ProfileCard.Header>
</View>
</View>
)
}
function StatusHeader({
numMatches,
isPending,
isAnyUnfollowed,
}: {
numMatches: number
isPending: boolean
isAnyUnfollowed: boolean
}) {
const {_} = useLingui()
const agent = useAgent()
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const {
mutate: onFollowAll,
isPending: isFollowingAll,
isSuccess: hasFollowedAll,
} = useMutation({
mutationFn: async () => {
const didsToFollow = []
let cursor: string | undefined
do {
const page = await agent.app.bsky.contact.getMatches({
limit: 100,
cursor,
})
cursor = page.data.cursor
for (const profile of page.data.matches) {
if (
profile.did !== currentAccount?.did &&
!isBlockedOrBlocking(profile) &&
!isMuted(profile) &&
!profile.viewer?.following
) {
didsToFollow.push(profile.did)
}
}
} while (cursor)
logger.metric('contacts:settings:followAll', {
followCount: didsToFollow.length,
})
const uris = await wait(500, bulkWriteFollows(agent, didsToFollow))
for (const did of didsToFollow) {
const uri = uris.get(did)
updateProfileShadow(queryClient, did, {
followingUri: uri,
})
}
},
onSuccess: () => {
Toast.show(_(msg`Followed all matches`), {type: 'success'})
},
onError: err => {
if (isNetworkError(err)) {
Toast.show(
_(
msg`Could not follow all matches - please check your network connection.`,
),
{type: 'error'},
)
} else {
logger.error('Failed to follow all matches', {safeMessage: err})
Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
if (numMatches > 0) {
if (isPending) {
return (
<View style={[a.w_full, a.py_3xl, a.align_center]}>
<Loader size="xl" />
</View>
)
}
return (
<View
style={[
a.pt_xl,
a.px_xl,
a.pb_md,
a.flex_row,
a.justify_between,
a.align_center,
]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Plural
value={numMatches}
one="1 contact found"
other="# contacts found"
/>
</Text>
{isAnyUnfollowed && (
<Button
label={_(msg`Follow all`)}
color="primary"
size="small"
variant="ghost"
onPress={() => onFollowAll()}
disabled={isFollowingAll || hasFollowedAll}
hitSlop={HITSLOP_10}
style={[a.px_0, a.py_0, a.rounded_0]}
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
<ButtonText>
<Trans>Follow all</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
return null
}
function StatusFooter({syncedAt}: {syncedAt: string}) {
const {_, i18n} = useLingui()
const t = useTheme()
const agent = useAgent()
const queryClient = useQueryClient()
const {mutate: removeData, isPending} = useMutation({
mutationFn: async () => {
await agent.app.bsky.contact.removeData({})
},
onMutate: () => logger.metric('contacts:settings:removeData', {}),
onSuccess: () => {
Toast.show(_(msg`Contacts removed`))
queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>(
findContactsStatusQueryKey,
{syncStatus: undefined},
)
},
onError: err => {
if (isNetworkError(err)) {
Toast.show(
_(
msg`Failed to remove data due to a network error, please check your internet connection.`,
),
{type: 'error'},
)
} else {
logger.error('Remove data failed', {safeMessage: err})
Toast.show(_(msg`Failed to remove data. ${cleanError(err)}`), {
type: 'error',
})
}
},
})
return (
<View style={[a.px_xl, a.py_xl, a.gap_4xl]}>
<View style={[a.gap_xs, a.align_start]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Contacts uploaded</Trans>
</Text>
<View style={[a.gap_2xs]}>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>We will notify you when we find your friends.</Trans>
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Uploaded on{' '}
{i18n.date(new Date(syncedAt), {
dateStyle: 'long',
})}
</Trans>
</Text>
</View>
<Link
label={_(msg`Resync contacts`)}
to={{screen: 'FindContactsFlow'}}
onPress={() => {
const daysSinceLastSync = Math.floor(
(Date.now() - new Date(syncedAt).getTime()) /
(1000 * 60 * 60 * 24),
)
logger.metric('contacts:settings:resync', {
daysSinceLastSync,
})
}}
size="small"
color="primary_subtle"
style={[a.mt_xs]}>
<ButtonIcon icon={ResyncIcon} />
<ButtonText>
<Trans>Resync contacts</Trans>
</ButtonText>
</Link>
</View>
<View style={[a.gap_xs, a.align_start]}>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Delete contacts</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Bluesky stores your contacts as encoded data. Removing your contacts
will immediately delete this data.
</Trans>
</Text>
<Button
label={_(msg`Remove all contacts`)}
onPress={() => removeData()}
size="small"
color="negative_subtle"
disabled={isPending}
style={[a.mt_xs]}>
<ButtonIcon icon={isPending ? Loader : TrashIcon} />
<ButtonText>
<Trans>Remove all contacts</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
+14
View File
@@ -37,6 +37,7 @@ import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
import {AvatarStackWithFetch} from '#/components/AvatarStack'
import {Button, ButtonText} from '#/components/Button'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
@@ -45,6 +46,7 @@ import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components
import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
@@ -89,6 +91,8 @@ export function SettingsScreen({}: Props) {
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const [showAccounts, setShowAccounts] = useState(false)
const [showDevOptions, setShowDevOptions] = useState(false)
const findContactsEnabled =
useIsFindContactsFeatureEnabledBasedOnGeolocation()
return (
<Layout.Screen>
@@ -207,6 +211,16 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
{isNative && findContactsEnabled && (
<SettingsList.LinkItem
to="/settings/find-contacts"
label={_(msg`Find friends from contacts`)}>
<SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText>
<Trans>Find friends from contacts</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
)}
<SettingsList.LinkItem
to="/settings/appearance"
label={_(msg`Appearance`)}>
+1 -1
View File
@@ -5,7 +5,7 @@ import {useMutation, useQuery} from '@tanstack/react-query'
import * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
import {Loader} from '#/components/Loader'
import * as SettingsList from '../components/SettingsList'
+2
View File
@@ -7,6 +7,7 @@ import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} from '#/state/queries/find-contacts'
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
@@ -256,4 +257,5 @@ function* findProfilesInCache(
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(queryClient, did)
yield* findAllProfilesInContactMatchesQueryData(queryClient, did)
}
+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,
}
@@ -1,11 +1,4 @@
import {
memo,
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import {memo, useCallback, useEffect, useMemo, useState} from 'react'
import {
Animated,
type GestureResponderEvent,
@@ -61,6 +54,7 @@ import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron'
import {Contacts_Filled_Corner2_Rounded as ContactsIconFilled} from '#/components/icons/Contacts'
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
@@ -116,6 +110,7 @@ let NotificationFeedItem = ({
break
}
case 'follow':
case 'contact-match':
case 'verified':
case 'unverified': {
return makeProfileLink(item.notification.author)
@@ -279,7 +274,7 @@ let NotificationFeedItem = ({
: ''
let a11yLabel = ''
let notificationContent: ReactElement<any>
let notificationContent: React.ReactElement<any>
let icon = (
<HeartIconFilled
size="xl"
@@ -373,6 +368,14 @@ let NotificationFeedItem = ({
)
}
icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} />
} else if (item.type === 'contact-match') {
a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`)
notificationContent = (
<Trans>Your contact {firstAuthorLink} is on Bluesky</Trans>
)
icon = (
<ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} />
)
} else if (item.type === 'feedgen-like') {
a11yLabel = hasMultipleAuthors
? _(
@@ -438,7 +441,7 @@ let NotificationFeedItem = ({
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}>
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -463,7 +466,7 @@ let NotificationFeedItem = ({
notificationContent = hasMultipleAuthors ? (
<Trans>
{firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}>
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
@@ -670,7 +673,9 @@ let NotificationFeedItem = ({
</TimeElapsed>
</Text>
</ExpandListPressable>
{item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? (
{(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) ||
(item.type === 'contact-match' &&
!item.notification.author.viewer?.following) ? (
<FollowBackButton profile={item.notification.author} />
) : null}
{item.type === 'post-like' ||
@@ -809,6 +814,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
}
const isFollowing = profileShadow.viewer.following
const isFollowedBy = profileShadow.viewer.followedBy
const followingLabel = _(
msg({
message: 'Following',
@@ -832,14 +838,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
</Button>
) : (
<Button
label={_(msg`Follow back`)}
label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)}
color="primary"
size="small"
style={[a.self_start]}
onPress={onPressFollow}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
<Trans>Follow back</Trans>
{isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>}
</ButtonText>
</Button>
)}
+2
View File
@@ -12,6 +12,7 @@ import {isWeb} from '#/platform/detection'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useSession} from '#/state/session'
import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX'
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {List} from '../util/List'
@@ -208,6 +209,7 @@ export function ProfileFollows({name}: {name: string}) {
onEndReached={onEndReached}
onEndReachedThreshold={4}
onItemSeen={onItemSeen}
ListHeaderComponent={<FindContactsBannerNUX />}
ListFooterComponent={
<ListFooter
isFetchingNextPage={isFetchingNextPage}
+1 -1
View File
@@ -37,7 +37,7 @@ import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/Do
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import {
+1 -1
View File
@@ -9,7 +9,7 @@ import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
+1 -1
View File
@@ -13,7 +13,7 @@ import {
Text as AdmonitionText,
} from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging'
import {InlineLinkText} from '#/components/Link'
import {H1} from '#/components/Typography'
+1 -1
View File
@@ -1,7 +1,7 @@
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
// import {useDialogStateControlContext} from '#/state/dialogs'
+1 -1
View File
@@ -41,7 +41,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -44,7 +44,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -32,7 +32,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -56,7 +56,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass'
import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+480 -502
View File
File diff suppressed because it is too large Load Diff