[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-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
['expo-location'], ['expo-location'],
[
'expo-contacts',
{
contactsPermission:
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
},
],
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { 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/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric) e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", 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", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric) e.GET("/sys/log", server.WebGeneric)
+2
View File
@@ -112,6 +112,8 @@ This is NOT required for app development but if you also want to develop the Blu
- Launch a Postgres database on port 5432 - Launch a Postgres database on port 5432
- `cd packages/dev-env && pnpm start` - `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. Then, when logging in or creating an account, point it to the localhost port of the devserver.
## Go-Server Build ## Go-Server Build
+37 -34
View File
@@ -72,10 +72,10 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.18.6", "@atproto/api": "^0.18.8",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@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", "@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -132,43 +132,46 @@
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0", "emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^54.0.22", "expo": "^54.0.27",
"expo-application": "~7.0.7", "expo-application": "~7.0.8",
"expo-blur": "~15.0.7", "expo-blur": "~15.0.8",
"expo-build-properties": "~1.0.9", "expo-build-properties": "~1.0.10",
"expo-camera": "~17.0.9", "expo-camera": "~17.0.10",
"expo-clipboard": "~8.0.7", "expo-clipboard": "~8.0.8",
"expo-dev-client": "~6.0.16", "expo-contacts": "^15.0.10",
"expo-device": "~8.0.9", "expo-dev-client": "~6.0.20",
"expo-file-system": "~19.0.17", "expo-device": "~8.0.10",
"expo-font": "~14.0.9", "expo-file-system": "~19.0.20",
"expo-haptics": "~15.0.7", "expo-font": "~14.0.10",
"expo-image": "~3.0.10", "expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-crop-tool": "^0.4.0", "expo-image-crop-tool": "^0.4.0",
"expo-image-manipulator": "~14.0.7", "expo-image-manipulator": "~14.0.8",
"expo-image-picker": "~17.0.8", "expo-image-picker": "~17.0.9",
"expo-intent-launcher": "~13.0.7", "expo-intent-launcher": "~13.0.8",
"expo-keep-awake": "^15.0.7", "expo-keep-awake": "~15.0.8",
"expo-linear-gradient": "~15.0.7", "expo-linear-gradient": "~15.0.8",
"expo-linking": "~8.0.8", "expo-linking": "~8.0.10",
"expo-localization": "~17.0.7", "expo-localization": "~17.0.8",
"expo-location": "~19.0.7", "expo-location": "~19.0.8",
"expo-media-library": "~18.2.0", "expo-media-library": "~18.2.1",
"expo-notifications": "~0.32.12", "expo-notifications": "~0.32.14",
"expo-screen-orientation": "~9.0.7", "expo-screen-orientation": "~9.0.8",
"expo-sharing": "~14.0.7", "expo-sharing": "~14.0.8",
"expo-splash-screen": "~31.0.10", "expo-sms": "^14.0.7",
"expo-system-ui": "~6.0.8", "expo-splash-screen": "~31.0.12",
"expo-task-manager": "~14.0.8", "expo-system-ui": "~6.0.9",
"expo-updates": "~29.0.12", "expo-task-manager": "~14.0.9",
"expo-video": "~3.0.13", "expo-updates": "~29.0.14",
"expo-web-browser": "~15.0.9", "expo-video": "~3.0.15",
"expo-web-browser": "~15.0.10",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
"hls.js": "^1.6.2", "hls.js": "^1.6.2",
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.12.31",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@@ -222,7 +225,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.3.193", "@atproto/dev-env": "^0.3.196",
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
@@ -264,7 +267,7 @@
"husky": "^8.0.3", "husky": "^8.0.3",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-expo": "^54.0.14", "jest-expo": "~54.0.14",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"lockfile-lint": "^4.14.0", "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 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 index 68086bd..78c7761 100644
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
+23 -4
View File
@@ -18,7 +18,9 @@ import {
} from '@react-navigation/native' } from '@react-navigation/native'
import {timeout} from '#/lib/async/timeout' import {timeout} from '#/lib/async/timeout'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import { import {
getNotificationPayload, getNotificationPayload,
type NotificationPayload, type NotificationPayload,
@@ -45,10 +47,12 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import { import {
shouldRequestEmailConfirmation, shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt, snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders' } from '#/state/shell/reminders'
import {useCloseAllActiveElements} from '#/state/util'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod' 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 {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {BookmarksScreen} from '#/screens/Bookmarks' import {BookmarksScreen} from '#/screens/Bookmarks'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {LogScreen} from '#/screens/Log' import {LogScreen} from '#/screens/Log'
import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesScreen} from '#/screens/Messages/ChatList'
@@ -102,6 +107,7 @@ import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
@@ -134,10 +140,6 @@ import {
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' 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>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -416,6 +418,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, requireAuth: true,
}} }}
/> />
<Stack.Screen
name="FindContactsSettings"
getComponent={() => FindContactsSettingsScreen}
options={{
title: title(msg`Find Contacts`),
requireAuth: true,
}}
/>
<Stack.Screen <Stack.Screen
name="NotificationSettings" name="NotificationSettings"
getComponent={() => NotificationSettingsScreen} getComponent={() => NotificationSettingsScreen}
@@ -609,6 +619,15 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, 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 {useLingui} from '@lingui/react'
import { import {
type CountryCode,
getDefaultCountry, getDefaultCountry,
INTERNATIONAL_TELEPHONE_CODES, INTERNATIONAL_TELEPHONE_CODES,
} from '#/lib/international-telephone-codes' } from '#/lib/international-telephone-codes'
@@ -23,8 +24,8 @@ export function InternationalPhoneCodeSelect({
value, value,
onChange, onChange,
}: { }: {
value?: string value?: CountryCode
onChange: (value: string) => void onChange: (value: CountryCode) => void
}) { }) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const location = useGeolocation() const location = useGeolocation()
@@ -62,7 +63,7 @@ export function InternationalPhoneCodeSelect({
}, [value, items]) }, [value, items])
return ( 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.Trigger label={_(msg`Select telephone code`)}>
<Select.ValueText placeholder="+..." webOverrideValue={selected}> <Select.ValueText placeholder="+..." webOverrideValue={selected}>
{selected => ( {selected => (
+3 -2
View File
@@ -38,7 +38,7 @@ export function Outer({
}: { }: {
children: React.ReactNode children: React.ReactNode
noBottomBorder?: boolean noBottomBorder?: boolean
headerRef?: React.MutableRefObject<View | null> headerRef?: React.RefObject<View | null>
sticky?: boolean sticky?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
@@ -129,6 +129,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
size="small" size="small"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
shape="round"
onPress={onPressBack} onPress={onPressBack}
hitSlop={HITSLOP_30} hitSlop={HITSLOP_30}
style={[ style={[
@@ -183,7 +184,7 @@ export function TitleText({
<Text <Text
style={[ style={[
a.text_lg, a.text_lg,
a.font_bold, a.font_semi_bold,
a.leading_tight, a.leading_tight,
isIOS && align === 'platform' && a.text_center, isIOS && align === 'platform' && a.text_center,
gtMobile && a.text_xl, gtMobile && a.text_xl,
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text as TypoText} from '#/components/Typography' 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 t = useTheme()
const handle = sanitizeHandle(profile.handle, '@') const handle = sanitizeHandle(profile.handle, '@')
return ( return (
<Text <Text
emoji emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]} style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
numberOfLines={1}> numberOfLines={1}>
{handle} {handle}
</Text> </Text>
@@ -31,7 +31,7 @@ import {
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState' 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 {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {boostInterests, InterestTabs} from '#/components/InterestTabs' import {boostInterests, InterestTabs} from '#/components/InterestTabs'
+1 -1
View File
@@ -12,7 +12,7 @@ import {
useRemoveMutedWordsMutation, useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation, useUpsertMutedWordsMutation,
} from '#/state/queries/preferences' } 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 {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import { import {
+4 -5
View File
@@ -1,10 +1,9 @@
import {View} from 'react-native' import {View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints} from '#/alf' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
export function SearchError({ export function SearchError({
title, title,
@@ -14,7 +13,7 @@ export function SearchError({
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const pal = usePalette('default') const t = useTheme()
return ( return (
<Layout.Content> <Layout.Content>
@@ -27,7 +26,7 @@ export function SearchError({
paddingVertical: 150, paddingVertical: 150,
}, },
]}> ]}>
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} /> <XIcon width={32} style={[t.atoms.text_contrast_low]} />
<View <View
style={[ style={[
a.align_center, 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 * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 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' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
export function GifSelectDialog({ export function GifSelectDialog({
@@ -25,7 +25,7 @@ import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {canBeMessaged} from '#/components/dms/util' import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState' 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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography' 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
who’s 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 {type AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs'
import { import {
@@ -12,11 +20,11 @@ import {
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session' import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell' import {useOnboardingState} from '#/state/shell'
import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement' import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
/* /*
* NUXs * NUXs
*/ */
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' import {FindContactsAnnouncement} from './FindContactsAnnouncement'
import {isExistingUserAsOf} from './utils' import {isExistingUserAsOf} from './utils'
type Context = { type Context = {
@@ -34,24 +42,24 @@ const queuedNuxs: {
}) => boolean }) => boolean
}[] = [ }[] = [
{ {
id: Nux.BookmarksAnnouncement, id: Nux.FindContactsAnnouncement,
enabled: ({currentProfile}) => { enabled: ({currentProfile}) => {
return isExistingUserAsOf( return (
'2025-09-08T00:00:00.000Z', isNative &&
currentProfile.createdAt, isExistingUserAsOf('2025-12-16T00:00:00.000Z', currentProfile.createdAt)
) )
}, },
}, },
] ]
const Context = React.createContext<Context>({ const Context = createContext<Context>({
activeNux: undefined, activeNux: undefined,
dismissActiveNux: () => {}, dismissActiveNux: () => {},
}) })
Context.displayName = 'NuxDialogContext' Context.displayName = 'NuxDialogContext'
export function useNuxDialogContext() { export function useNuxDialogContext() {
return React.useContext(Context) return useContext(Context)
} }
export function NuxDialogs() { export function NuxDialogs() {
@@ -92,19 +100,19 @@ function Inner({
}) { }) {
const gate = useGate() const gate = useGate()
const {nuxs} = useNuxs() const {nuxs} = useNuxs()
const [snoozed, setSnoozed] = React.useState(() => { const [snoozed, setSnoozed] = useState(() => {
return isSnoozed() return isSnoozed()
}) })
const [activeNux, setActiveNux] = React.useState<Nux | undefined>() const [activeNux, setActiveNux] = useState<Nux | undefined>()
const {mutateAsync: saveNux} = useSaveNux() const {mutateAsync: saveNux} = useSaveNux()
const {mutate: resetNuxs} = useResetNuxs() const {mutate: resetNuxs} = useResetNuxs()
const snoozeNuxDialog = React.useCallback(() => { const snoozeNuxDialog = useCallback(() => {
snooze() snooze()
setSnoozed(true) setSnoozed(true)
}, [setSnoozed]) }, [setSnoozed])
const dismissActiveNux = React.useCallback(() => { const dismissActiveNux = useCallback(() => {
if (!activeNux) return if (!activeNux) return
setActiveNux(undefined) setActiveNux(undefined)
}, [activeNux, setActiveNux]) }, [activeNux, setActiveNux])
@@ -118,7 +126,7 @@ function Inner({
} }
} }
React.useEffect(() => { useEffect(() => {
if (snoozed) return // comment this out to test if (snoozed) return // comment this out to test
if (!nuxs) return if (!nuxs) return
@@ -170,7 +178,7 @@ function Inner({
preferences, preferences,
]) ])
const ctx = React.useMemo(() => { const ctx = useMemo(() => {
return { return {
activeNux, activeNux,
dismissActiveNux, dismissActiveNux,
@@ -180,7 +188,9 @@ function Inner({
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />} {activeNux === Nux.FindContactsAnnouncement && (
<FindContactsAnnouncement />
)}
</Context.Provider> </Context.Provider>
) )
} }
+1 -1
View File
@@ -8,7 +8,7 @@ import {isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import * as TextField from '#/components/forms/TextField' 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' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
type SearchInputProps = Omit<TextField.InputProps, 'label'> & { type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
+14 -8
View File
@@ -141,17 +141,28 @@ export function useSharedInputStyles() {
}, [t]) }, [t])
} }
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { export type InputProps = Omit<
TextInputProps,
'value' | 'onChangeText' | 'placeholder'
> & {
label: string label: string
/** /**
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible. * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
* *
* See https://github.com/facebook/react-native-website/pull/4247 * 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 value?: string
onChangeText?: (value: string) => void onChangeText?: (value: string) => void
isInvalid?: boolean isInvalid?: boolean
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput> 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) { export function createInput(Component: typeof TextInput) {
@@ -255,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
ctx.onBlur() ctx.onBlur()
onBlur?.(e) onBlur?.(e)
}} }}
placeholder={placeholder || label} placeholder={placeholder === null ? undefined : placeholder || label}
placeholderTextColor={t.palette.contrast_500} placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
style={flattened} style={flattened}
@@ -292,12 +303,7 @@ export function LabelText({
return ( return (
<Text <Text
nativeID={nativeID} nativeID={nativeID}
style={[ style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}>
a.text_sm,
a.font_semi_bold,
t.atoms.text_contrast_medium,
a.mb_sm,
]}>
{children} {children}
</Text> </Text>
) )
@@ -4,3 +4,9 @@ export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
createSinglePathSVG({ 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', 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' 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 = export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded =
createSinglePathSVG({ 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', 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', 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({ 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', 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 {type DialogControlProps} from '#/components/Dialog'
import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
import {Divider} from '#/components/Divider' 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 {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -18,7 +18,7 @@ import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 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 { import {
Check_Stroke2_Corner0_Rounded as CheckThin, Check_Stroke2_Corner0_Rounded as CheckThin,
CheckThick_Stroke2_Corner0_Rounded as Check, 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 = { export const INTERNATIONAL_TELEPHONE_CODES = {
AD: { AD: {
code: '+376', code: '+376',
@@ -34,11 +45,13 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇦🇴', unicodeFlag: '🇦🇴',
svgFlag: require('../../assets/icons/flags/AO.svg'), svgFlag: require('../../assets/icons/flags/AO.svg'),
}, },
AQ: { // sorry penguins :(
code: '+672', // same as Norfolk Island
unicodeFlag: '🇦🇶', // AQ: {
svgFlag: require('../../assets/icons/flags/AQ.svg'), // code: '+672',
}, // unicodeFlag: '🇦🇶',
// svgFlag: require('../../assets/icons/flags/AQ.svg'),
// },
AR: { AR: {
code: '+54', code: '+54',
unicodeFlag: '🇦🇷', unicodeFlag: '🇦🇷',
@@ -154,11 +167,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇧🇹', unicodeFlag: '🇧🇹',
svgFlag: require('../../assets/icons/flags/BT.svg'), svgFlag: require('../../assets/icons/flags/BT.svg'),
}, },
BV: { // same as Norway
code: '+47', // BV: {
unicodeFlag: '🇧🇻', // code: '+47',
svgFlag: require('../../assets/icons/flags/BV.svg'), // unicodeFlag: '🇧🇻',
}, // svgFlag: require('../../assets/icons/flags/BV.svg'),
// },
BW: { BW: {
code: '+267', code: '+267',
unicodeFlag: '🇧🇼', unicodeFlag: '🇧🇼',
@@ -379,11 +393,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇬🇷', unicodeFlag: '🇬🇷',
svgFlag: require('../../assets/icons/flags/GR.svg'), svgFlag: require('../../assets/icons/flags/GR.svg'),
}, },
GS: { // same as Falkland Islands
code: '+500', // GS: {
unicodeFlag: '🇬🇸', // code: '+500',
svgFlag: require('../../assets/icons/flags/GS.svg'), // unicodeFlag: '🇬🇸',
}, // svgFlag: require('../../assets/icons/flags/GS.svg'),
// },
GT: { GT: {
code: '+502', code: '+502',
unicodeFlag: '🇬🇹', unicodeFlag: '🇬🇹',
@@ -779,11 +794,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇵🇲', unicodeFlag: '🇵🇲',
svgFlag: require('../../assets/icons/flags/PM.svg'), svgFlag: require('../../assets/icons/flags/PM.svg'),
}, },
PN: { // same as New Zealand
code: '+64', // PN: {
unicodeFlag: '🇵🇳', // code: '+64',
svgFlag: require('../../assets/icons/flags/PN.svg'), // unicodeFlag: '🇵🇳',
}, // svgFlag: require('../../assets/icons/flags/PN.svg'),
// },
PR: { PR: {
code: '+1', code: '+1',
unicodeFlag: '🇵🇷', unicodeFlag: '🇵🇷',
@@ -1199,11 +1215,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇫🇴', unicodeFlag: '🇫🇴',
svgFlag: require('../../assets/icons/flags/FO.svg'), svgFlag: require('../../assets/icons/flags/FO.svg'),
}, },
HM: { // same as Norfolk Island
code: '+672', // HM: {
unicodeFlag: '🇭🇲', // code: '+672',
svgFlag: require('../../assets/icons/flags/HM.svg'), // unicodeFlag: '🇭🇲',
}, // svgFlag: require('../../assets/icons/flags/HM.svg'),
// },
KM: { KM: {
code: '+269', code: '+269',
unicodeFlag: '🇰🇲', unicodeFlag: '🇰🇲',
@@ -1229,16 +1246,18 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇹🇨', unicodeFlag: '🇹🇨',
svgFlag: require('../../assets/icons/flags/TC.svg'), svgFlag: require('../../assets/icons/flags/TC.svg'),
}, },
TF: { // same as Norfolk Island
code: '+672', // TF: {
unicodeFlag: '🇹🇫', // code: '+672',
svgFlag: require('../../assets/icons/flags/TF.svg'), // unicodeFlag: '🇹🇫',
}, // svgFlag: require('../../assets/icons/flags/TF.svg'),
UM: { // },
code: '+1', // same as US mainland
unicodeFlag: '🇺🇲', // UM: {
svgFlag: require('../../assets/icons/flags/UM.svg'), // code: '+1',
}, // unicodeFlag: '🇺🇲',
// svgFlag: require('../../assets/icons/flags/UM.svg'),
// },
VA: { VA: {
code: '+39', code: '+39',
unicodeFlag: '🇻🇦', unicodeFlag: '🇻🇦',
@@ -1249,25 +1268,26 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
unicodeFlag: '🇽🇰', unicodeFlag: '🇽🇰',
svgFlag: require('../../assets/icons/flags/XK.svg'), 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 ( if (
location?.countryCode && locationCountryCode &&
location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES locationCountryCode in INTERNATIONAL_TELEPHONE_CODES
) { ) {
return location.countryCode.toUpperCase() return locationCountryCode as CountryCode
} }
return DEFAULT_PHONE_COUNTRY 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 InterestsSettings: undefined
AboutSettings: undefined AboutSettings: undefined
AppIconSettings: undefined AppIconSettings: undefined
FindContactsSettings: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Topic: {topic: string} Topic: {topic: string}
@@ -87,6 +88,7 @@ export type CommonNavigatorParams = {
StarterPackEdit: {rkey?: string} StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext VideoFeed: VideoFeedSourceContext
Bookmarks: undefined Bookmarks: undefined
FindContactsFlow: undefined
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'alt_share_icon' | 'alt_share_icon'
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'debug_subscriptions' | 'debug_subscriptions'
| 'disable_onboarding_find_contacts'
| 'explore_show_suggested_feeds' | 'explore_show_suggested_feeds'
| 'feed_reply_button_open_thread' | 'feed_reply_button_open_thread'
| 'old_postonboarding' | 'old_postonboarding'
+89
View File
@@ -309,6 +309,7 @@ export type MetricEvents = {
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts' | 'OnboardingSuggestedAccounts'
| 'FindContacts'
} }
'profile:followers:view': { 'profile:followers:view': {
contextProfileDid: string contextProfileDid: string
@@ -396,6 +397,7 @@ export type MetricEvents = {
| 'ImmersiveVideo' | 'ImmersiveVideo'
| 'ExploreSuggestedAccounts' | 'ExploreSuggestedAccounts'
| 'OnboardingSuggestedAccounts' | 'OnboardingSuggestedAccounts'
| 'FindContacts'
} }
'chat:create': { 'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -625,4 +627,91 @@ export type MetricEvents = {
'blockedGeoOverlay:shown': {} 'blockedGeoOverlay:shown': {}
'geo:debug': {} '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', '/settings/notifications/reposts-on-reposts',
ActivityNotificationSettings: '/settings/notifications/activity', ActivityNotificationSettings: '/settings/notifications/activity',
MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous', MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous',
FindContactsSettings: '/settings/find-contacts',
// support // support
Support: '/support', Support: '/support',
PrivacyPolicy: '/support/privacy', PrivacyPolicy: '/support/privacy',
@@ -91,4 +92,5 @@ export const router = new Router<AllNavigatableRoutes>({
StarterPackWizard: '/starter-pack/create', StarterPackWizard: '/starter-pack/create',
VideoFeed: '/video-feed', VideoFeed: '/video-feed',
Bookmarks: '/saved', 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 {type DialogControlProps, useDialogControl} from '#/components/Dialog'
import {NewChat} from '#/components/dms/dialogs/NewChatDialog' import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 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 {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+101 -89
View File
@@ -1,13 +1,12 @@
import React, {useState} from 'react' import {useEffect, useRef, useState} from 'react'
import {ScrollView, View} from 'react-native' import {ScrollView, View} from 'react-native'
import {Dimensions} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context' 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 {useLingui} from '@lingui/react'
import {isAndroid, isWeb} from '#/platform/detection' import {isAndroid, isWeb} from '#/platform/detection'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import { import {
atoms as a, atoms as a,
native, native,
@@ -22,6 +21,7 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons
import {HEADER_SLOT_SIZE} from '#/components/Layout' import {HEADER_SLOT_SIZE} from '#/components/Layout'
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
import {P, Text} from '#/components/Typography' import {P, Text} from '#/components/Typography'
import {IS_INTERNAL} from '#/env'
const ONBOARDING_COL_WIDTH = 420 const ONBOARDING_COL_WIDTH = 420
@@ -34,20 +34,20 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const scrollview = React.useRef<ScrollView>(null) const scrollview = useRef<ScrollView>(null)
const prevActiveStep = React.useRef<string>(state.activeStep) const prevActiveStep = useRef<string>(state.activeStep)
React.useEffect(() => { useEffect(() => {
if (state.activeStep !== prevActiveStep.current) { if (state.activeStep !== prevActiveStep.current) {
prevActiveStep.current = state.activeStep prevActiveStep.current = state.activeStep
scrollview.current?.scrollTo({y: 0, animated: false}) scrollview.current?.scrollTo({y: 0, animated: false})
} }
}, [state]) }, [state])
const paddingTop = gtMobile ? a.py_5xl : a.py_lg
const dialogLabel = _(msg`Set up your account`) const dialogLabel = _(msg`Set up your account`)
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0)
return ( return (
@@ -59,33 +59,12 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
accessibilityLabel={dialogLabel} accessibilityLabel={dialogLabel}
accessibilityHint={_(msg`Customizes your Bluesky experience`)} accessibilityHint={_(msg`Customizes your Bluesky experience`)}
style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}> style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
{__DEV__ && ( {!gtMobile ? (
<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 && (
<View <View
pointerEvents="box-none"
style={[ style={[
web(a.fixed), web(a.fixed),
native(a.absolute), native(a.absolute),
a.top_0,
a.left_0, a.left_0,
a.right_0, a.right_0,
a.flex_row, a.flex_row,
@@ -93,85 +72,94 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
a.justify_center, a.justify_center,
a.z_20, a.z_20,
a.px_xl, 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 <View
pointerEvents="box-none"
style={[ style={[
a.w_full, a.w_full,
a.align_start, a.align_center,
a.flex_row, a.flex_row,
a.justify_between, a.justify_between,
{maxWidth: ONBOARDING_COL_WIDTH}, web({maxWidth: ONBOARDING_COL_WIDTH}),
web(a.pointer_events_box_none),
]}> ]}>
{state.hasPrev ? ( <HeaderSlot>
{state.canGoBack && (
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" color="secondary"
variant="ghost" variant="ghost"
shape="square" shape="round"
size="small" size="small"
label={_(msg`Go back to previous step`)} label={_(msg`Go back to previous step`)}
onPress={() => dispatch({type: 'prev'})} onPress={() => dispatch({type: 'prev'})}>
style={[a.bg_transparent]}>
<ButtonIcon icon={ArrowLeft} size="lg" /> <ButtonIcon icon={ArrowLeft} size="lg" />
</Button> </Button>
) : ( )}
<View /> </HeaderSlot>
{IS_INTERNAL && (
<Button
variant="ghost"
color="negative"
size="tiny"
onPress={() => onboardDispatch({type: 'skip'})}
// DEV ONLY
label="Clear onboarding state">
<ButtonText>[DEV] Clear</ButtonText>
</Button>
)} )}
<HeaderSlot>
<OnboardingHeaderSlot.Outlet /> <OnboardingHeaderSlot.Outlet />
</HeaderSlot>
</View> </View>
</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 <ScrollView
ref={scrollview} ref={scrollview}
style={[a.h_full, a.w_full, {paddingTop: insets.top}]} style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0, minHeight: '100%'}} contentContainerStyle={{
borderWidth: 0,
minHeight: '100%',
paddingTop: gtMobile ? 40 : headerHeight,
paddingBottom: footerHeight,
}}
showsVerticalScrollIndicator={!isAndroid} showsVerticalScrollIndicator={!isAndroid}
scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
// @ts-expect-error web only --prf // @ts-expect-error web only --prf
dataSet={{'stable-gutters': 1}}> dataSet={{'stable-gutters': 1}}
centerContent={gtMobile}>
<View <View
style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> 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.flex_1, web({maxWidth: ONBOARDING_COL_WIDTH})]}>
<View style={[a.w_full, a.align_center, paddingTop]}> <View style={[a.w_full, a.py_md]}>{children}</View>
<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> </View>
</View> </View>
</ScrollView> </ScrollView>
@@ -200,7 +188,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
gtMobile && [a.flex_row, a.justify_between, a.align_center], gtMobile && [a.flex_row, a.justify_between, a.align_center],
]}> ]}>
{gtMobile && {gtMobile &&
(state.hasPrev ? ( (state.canGoBack ? (
<Button <Button
key={state.activeStep} // remove focus state on nav key={state.activeStep} // remove focus state on nav
color="secondary" 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, children,
style, style,
}: React.PropsWithChildren<TextStyleProp>) { }: React.PropsWithChildren<TextStyleProp>) {
return ( return (
<Text <Text style={[a.text_3xl, a.font_bold, a.leading_snug, style]}>
style={[a.pb_sm, a.text_4xl, a.font_semi_bold, a.leading_tight, style]}>
{children} {children}
</Text> </Text>
) )
} }
export function DescriptionText({ export function OnboardingDescriptionText({
children, children,
style, style,
}: React.PropsWithChildren<TextStyleProp>) { }: React.PropsWithChildren<TextStyleProp>) {
const t = useTheme() 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 {View} from 'react-native'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
@@ -35,28 +35,23 @@ import {
useSetActiveStarterPack, useSetActiveStarterPack,
} from '#/state/shell/starter-pack' } from '#/state/shell/starter-pack'
import { import {
DescriptionText,
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot, OnboardingHeaderSlot,
TitleText,
} from '#/screens/Onboarding/Layout' } 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 {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 {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {ValuePropositionPager} from './ValuePropositionPager' import {ValuePropositionPager} from './ValuePropositionPager'
export function StepFinished() { export function StepFinished() {
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -247,18 +242,12 @@ export function StepFinished() {
gate, gate,
]) ])
return state.experiments?.onboarding_value_prop ? ( return (
<ValueProposition <ValueProposition
finishOnboarding={finishOnboarding} finishOnboarding={finishOnboarding}
saving={saving} saving={saving}
state={state} 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 {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
DescriptionText,
OnboardingControls, OnboardingControls,
TitleText, OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle' 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' import {Loader} from '#/components/Loader'
export function StepInterests() { export function StepInterests() {
const {_} = useLingui() const {_} = useLingui()
const interestsDisplayNames = useInterestsDisplayNames() const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const [selectedInterests, setSelectedInterests] = React.useState<string[]>( const [selectedInterests, setSelectedInterests] = React.useState<string[]>(
state.interestsStepResults.selectedInterests.map(i => i), state.interestsStepResults.selectedInterests.map(i => i),
@@ -53,17 +51,16 @@ export function StepInterests() {
}, [selectedInterests, setSaving, dispatch]) }, [selectedInterests, setSaving, dispatch])
return ( return (
<View style={[a.align_start]} testID="onboardingInterests"> <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<IconCircle icon={Hashtag} style={[a.mb_2xl]} /> <OnboardingPosition />
<OnboardingTitleText>
<TitleText>
<Trans>What are your interests?</Trans> <Trans>What are your interests?</Trans>
</TitleText> </OnboardingTitleText>
<DescriptionText> <OnboardingDescriptionText>
<Trans>We'll use this to help customize your experience.</Trans> <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 <Toggle.Group
values={selectedInterests} values={selectedInterests}
onChange={setSelectedInterests} onChange={setSelectedInterests}
@@ -93,7 +90,7 @@ export function StepInterests() {
<ButtonText> <ButtonText>
<Trans>Continue</Trans> <Trans>Continue</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={saving ? Loader : ChevronRight} /> {saving && <ButtonIcon icon={Loader} />}
</Button> </Button>
</OnboardingControls.Portal> </OnboardingControls.Portal>
</View> </View>
+18 -22
View File
@@ -19,11 +19,12 @@ import {isCancelledError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import { import {
DescriptionText,
OnboardingControls, OnboardingControls,
TitleText, OnboardingDescriptionText,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {Context} from '#/screens/Onboarding/state' import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle'
import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle'
import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
@@ -32,13 +33,10 @@ import {
type PlaceholderCanvasRef, type PlaceholderCanvasRef,
} from '#/screens/Onboarding/StepProfile/PlaceholderCanvas' } from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' 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 * as Dialog from '#/components/Dialog'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 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 {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 {Text} from '#/components/Typography'
import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types'
@@ -78,7 +76,7 @@ export function StepProfile() {
const creatorControl = Dialog.useDialogControl() const creatorControl = Dialog.useDialogControl()
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const [avatar, setAvatar] = React.useState<Avatar>({ const [avatar, setAvatar] = React.useState<Avatar>({
image: state.profileStepResults?.image, image: state.profileStepResults?.image,
placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at, placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at,
@@ -237,19 +235,21 @@ export function StepProfile() {
return ( return (
<AvatarContext.Provider value={value}> <AvatarContext.Provider value={value}>
<View style={[a.align_start, t.atoms.bg, a.justify_between]}> <View style={[a.align_start]}>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> <View style={[a.gap_sm]}>
<TitleText> <OnboardingPosition />
<OnboardingTitleText>
<Trans>Give your profile a face</Trans> <Trans>Give your profile a face</Trans>
</TitleText> </OnboardingTitleText>
<DescriptionText> <OnboardingDescriptionText>
<Trans> <Trans>
Help people know you're not a bot by uploading a picture or creating Help people know you're not a bot by uploading a picture or
an avatar. creating an avatar.
</Trans> </Trans>
</DescriptionText> </OnboardingDescriptionText>
</View>
<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 <AvatarCircle
openLibrary={openLibrary} openLibrary={openLibrary}
openCreator={creatorControl.open} openCreator={creatorControl.open}
@@ -279,7 +279,6 @@ export function StepProfile() {
<View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
<Button <Button
testID="onboardingContinue" testID="onboardingContinue"
variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Continue to next step`)} label={_(msg`Continue to next step`)}
@@ -287,12 +286,10 @@ export function StepProfile() {
<ButtonText> <ButtonText>
<Trans>Continue</Trans> <Trans>Continue</Trans>
</ButtonText> </ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button> </Button>
<Button <Button
testID="onboardingAvatarCreator" testID="onboardingAvatarCreator"
variant="ghost" color="primary_subtle"
color="primary"
size="large" size="large"
label={_(msg`Open avatar creator`)} label={_(msg`Open avatar creator`)}
onPress={onSecondaryPress}> onPress={onSecondaryPress}>
@@ -335,7 +332,6 @@ export function StepProfile() {
</View> </View>
<View style={[a.pt_4xl]}> <View style={[a.pt_4xl]}>
<Button <Button
variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Done`)} label={_(msg`Done`)}
@@ -1,11 +1,4 @@
import { import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {type ModerationOpts} from '@atproto/api' import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -22,19 +15,22 @@ import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {OnboardingControls} from '#/screens/Onboarding/Layout' import {
import {Context} from '#/screens/Onboarding/state' OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 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 {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {boostInterests, InterestTabs} from '#/components/InterestTabs' import {boostInterests, InterestTabs} from '#/components/InterestTabs'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as toast from '#/components/Toast' import * as toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {bulkWriteFollows} from '../util' import {bulkWriteFollows} from '../util'
@@ -47,7 +43,7 @@ export function StepSuggestedAccounts() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const [selectedInterest, setSelectedInterest] = useState<string | null>(null) const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
// keeping track of who was followed via the follow all button // keeping track of who was followed via the follow all button
@@ -153,18 +149,21 @@ export function StepSuggestedAccounts() {
) )
return ( return (
<View style={[a.align_start]} testID="onboardingInterests"> <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}> <OnboardingPosition />
<OnboardingTitleText>
<Trans comment="Accounts suggested to the user for them to follow"> <Trans comment="Accounts suggested to the user for them to follow">
Suggested for you Suggested for you
</Trans> </Trans>
</Text> </OnboardingTitleText>
<View <View
style={[ style={[
a.overflow_hidden, a.overflow_hidden,
a.mt_lg, a.mt_sm,
isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, isWeb
? [a.max_w_full, web({minHeight: '100vh'})]
: {marginHorizontal: tokens.space.xl * -1},
a.flex_1, a.flex_1,
a.justify_start, a.justify_start,
]}> ]}>
@@ -1,18 +1,20 @@
import {useContext} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery' import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery'
import {OnboardingControls} from '#/screens/Onboarding/Layout' import {
import {Context} from '#/screens/Onboarding/state' OnboardingControls,
OnboardingPosition,
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {StarterPackCard} from './StarterPackCard' import {StarterPackCard} from './StarterPackCard'
export function StepSuggestedStarterpacks() { export function StepSuggestedStarterpacks() {
@@ -20,7 +22,7 @@ export function StepSuggestedStarterpacks() {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {state, dispatch} = useContext(Context) const {state, dispatch} = useOnboardingInternalState()
const { const {
data: suggestedStarterPacks, data: suggestedStarterPacks,
@@ -34,26 +36,26 @@ export function StepSuggestedStarterpacks() {
}) })
return ( return (
<View style={[a.align_start]} testID="onboardingInterests"> <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<Text style={[a.font_bold, a.text_3xl]}> <OnboardingPosition />
<Trans comment="Accounts suggested to the user for them to follow"> <OnboardingTitleText>
Suggested for you <Trans comment="Starter packs suggested to the user for them to follow">
Find people to follow
</Trans> </Trans>
</Text> </OnboardingTitleText>
<View <View
style={[ style={[
a.overflow_hidden, a.overflow_hidden,
a.mt_lg,
a.flex_1, a.flex_1,
a.justify_start, a.justify_start,
a.w_full, a.w_full,
a.mt_sm,
]}> ]}>
{isLoading || !moderationOpts ? ( {isLoading || !moderationOpts ? (
<View <View
style={[ style={[
a.flex_1, a.flex_1,
a.mt_md,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
{minHeight: 400}, {minHeight: 400},
@@ -69,7 +71,7 @@ export function StepSuggestedStarterpacks() {
</Admonition> </Admonition>
</View> </View>
) : ( ) : (
<View style={[a.flex_1, a.mt_md]}> <View style={[a.flex_1]}>
{suggestedStarterPacks?.starterPacks.map(starterPack => ( {suggestedStarterPacks?.starterPacks.map(starterPack => (
<View style={[a.pb_lg]} key={starterPack.uri}> <View style={[a.pb_lg]} key={starterPack.uri}>
<StarterPackCard view={starterPack} /> <StarterPackCard view={starterPack} />
+47 -46
View File
@@ -1,28 +1,38 @@
import {useMemo, useReducer} from 'react' import {useMemo, useReducer} from 'react'
import {msg} from '@lingui/macro' import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match' import * as bcp47Match from 'bcp-47-match'
import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import { import {
Layout, Layout,
OnboardingControls, OnboardingControls,
OnboardingHeaderSlot, OnboardingHeaderSlot,
} from '#/screens/Onboarding/Layout' } 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 {StepFinished} from '#/screens/Onboarding/StepFinished'
import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepInterests} from '#/screens/Onboarding/StepInterests'
import {StepProfile} from '#/screens/Onboarding/StepProfile' 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 {Portal} from '#/components/Portal'
import {ScreenTransition} from '#/components/ScreenTransition' import {ScreenTransition} from '#/components/ScreenTransition'
import {ENV} from '#/env' import {ENV} from '#/env'
import {StepFindContacts} from './StepFindContacts'
import {StepFindContactsIntro} from './StepFindContactsIntro'
import {StepSuggestedAccounts} from './StepSuggestedAccounts' import {StepSuggestedAccounts} from './StepSuggestedAccounts'
import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks' import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks'
export function Onboarding() { export function Onboarding() {
const {_} = useLingui()
const gate = useGate() const gate = useGate()
const t = useTheme()
const {contentLanguages} = useLanguagePrefs() const {contentLanguages} = useLanguagePrefs()
const probablySpeaksEnglish = useMemo(() => { const probablySpeaksEnglish = useMemo(() => {
@@ -36,56 +46,42 @@ export function Onboarding() {
probablySpeaksEnglish && probablySpeaksEnglish &&
gate('onboarding_suggested_starterpacks') gate('onboarding_suggested_starterpacks')
const [state, dispatch] = useReducer(reducer, { const findContactsEnabled =
...initialState, useIsFindContactsFeatureEnabledBasedOnGeolocation()
totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0), const showFindContacts =
experiments: { isNative && findContactsEnabled && !gate('disable_onboarding_find_contacts')
onboarding_suggested_accounts: true,
onboarding_value_prop: true,
onboarding_suggested_starterpacks: showSuggestedStarterpacks,
},
})
const interestsDisplayNames = useMemo(() => { const [state, dispatch] = useReducer(
return { reducer,
news: _(msg`News`), {
journalism: _(msg`Journalism`), starterPacksStepEnabled: showSuggestedStarterpacks,
nature: _(msg`Nature`), findContactsStepEnabled: showFindContacts,
art: _(msg`Art`), },
comics: _(msg`Comics`), createInitialOnboardingState,
writers: _(msg`Writers`), )
culture: _(msg`Culture`), const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState()
sports: _(msg`Sports`),
pets: _(msg`Pets`), useEnableKeyboardControllerScreen(true)
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`),
}
}, [_])
return ( return (
<Portal> <Portal>
<View style={[a.absolute, a.inset_0, t.atoms.bg]}>
<OnboardingControls.Provider> <OnboardingControls.Provider>
<OnboardingHeaderSlot.Provider> <OnboardingHeaderSlot.Provider>
<Context.Provider <Context.Provider
value={useMemo( value={useMemo(() => ({state, dispatch}), [state, dispatch])}>
() => ({state, dispatch, interestsDisplayNames}),
[state, dispatch, interestsDisplayNames],
)}>
<Layout>
<ScreenTransition <ScreenTransition
key={state.activeStep} key={state.activeStep}
direction={state.stepTransitionDirection}> 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 === 'profile' && <StepProfile />}
{state.activeStep === 'interests' && <StepInterests />} {state.activeStep === 'interests' && <StepInterests />}
{state.activeStep === 'suggested-accounts' && ( {state.activeStep === 'suggested-accounts' && (
@@ -94,12 +90,17 @@ export function Onboarding() {
{state.activeStep === 'suggested-starterpacks' && ( {state.activeStep === 'suggested-starterpacks' && (
<StepSuggestedStarterpacks /> <StepSuggestedStarterpacks />
)} )}
{state.activeStep === 'find-contacts-intro' && (
<StepFindContactsIntro />
)}
{state.activeStep === 'finished' && <StepFinished />} {state.activeStep === 'finished' && <StepFinished />}
</ScreenTransition>
</Layout> </Layout>
)}
</ScreenTransition>
</Context.Provider> </Context.Provider>
</OnboardingHeaderSlot.Provider> </OnboardingHeaderSlot.Provider>
</OnboardingControls.Provider> </OnboardingControls.Provider>
</View>
</Portal> </Portal>
) )
} }
+113 -41
View File
@@ -1,4 +1,4 @@
import React from 'react' import {createContext, useContext, useMemo} from 'react'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
@@ -6,16 +6,18 @@ import {
type Emoji, type Emoji,
} from '#/screens/Onboarding/StepProfile/types' } from '#/screens/Onboarding/StepProfile/types'
export type OnboardingState = { type OnboardingScreen =
hasPrev: boolean
totalSteps: number
activeStep:
| 'profile' | 'profile'
| 'interests' | 'interests'
| 'suggested-accounts' | 'suggested-accounts'
| 'suggested-starterpacks' | 'suggested-starterpacks'
| 'find-contacts-intro'
| 'find-contacts'
| 'finished' | 'finished'
activeStepIndex: number
export type OnboardingState = {
screens: Record<OnboardingScreen, boolean>
activeStep: OnboardingScreen
stepTransitionDirection: 'Forward' | 'Backward' stepTransitionDirection: 'Forward' | 'Backward'
interestsStepResults: { interestsStepResults: {
@@ -37,12 +39,6 @@ export type OnboardingState = {
backgroundColor: AvatarColor backgroundColor: AvatarColor
} }
} }
experiments?: {
onboarding_suggested_accounts?: boolean
onboarding_value_prop?: boolean
onboarding_suggested_starterpacks?: boolean
}
} }
export type OnboardingAction = export type OnboardingAction =
@@ -52,6 +48,9 @@ export type OnboardingAction =
| { | {
type: 'prev' type: 'prev'
} }
| {
type: 'skip-contacts'
}
| { | {
type: 'finish' type: 'finish'
} }
@@ -73,13 +72,29 @@ export type OnboardingAction =
| undefined | undefined
} }
export const initialState: OnboardingState = { export function createInitialOnboardingState(
hasPrev: false, {
totalSteps: 3, starterPacksStepEnabled,
activeStep: 'profile', findContactsStepEnabled,
activeStepIndex: 1, }: {
stepTransitionDirection: 'Forward', 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,
}
return {
screens,
activeStep: 'profile',
stepTransitionDirection: 'Forward',
interestsStepResults: { interestsStepResults: {
selectedInterests: [], selectedInterests: [],
}, },
@@ -89,15 +104,13 @@ export const initialState: OnboardingState = {
imageUri: '', imageUri: '',
imageMime: '', imageMime: '',
}, },
}
} }
export const Context = React.createContext<{ export const Context = createContext<{
state: OnboardingState state: OnboardingState
dispatch: React.Dispatch<OnboardingAction> dispatch: React.Dispatch<OnboardingAction>
}>({ } | null>(null)
state: {...initialState},
dispatch: () => {},
})
Context.displayName = 'OnboardingContext' Context.displayName = 'OnboardingContext'
export function reducer( export function reducer(
@@ -106,42 +119,39 @@ export function reducer(
): OnboardingState { ): OnboardingState {
let next = {...s} let next = {...s}
const stepOrder: OnboardingState['activeStep'][] = [ const stepOrder = getStepOrder(s)
'profile',
'interests',
...(s.experiments?.onboarding_suggested_accounts
? (['suggested-accounts'] as const)
: []),
...(s.experiments?.onboarding_suggested_starterpacks
? (['suggested-starterpacks'] as const)
: []),
'finished',
]
switch (a.type) { switch (a.type) {
case 'next': { case 'next': {
// 1-indexed for some reason const nextIndex = stepOrder.indexOf(next.activeStep) + 1
const nextIndex = s.activeStepIndex
const nextStep = stepOrder[nextIndex] const nextStep = stepOrder[nextIndex]
if (nextStep) { if (nextStep) {
next.activeStep = nextStep next.activeStep = nextStep
next.activeStepIndex = nextIndex + 1
} }
next.stepTransitionDirection = 'Forward' next.stepTransitionDirection = 'Forward'
break break
} }
case 'prev': { case 'prev': {
const prevIndex = s.activeStepIndex - 2 const prevIndex = stepOrder.indexOf(next.activeStep) - 1
const prevStep = stepOrder[prevIndex] const prevStep = stepOrder[prevIndex]
if (prevStep) { if (prevStep) {
next.activeStep = prevStep next.activeStep = prevStep
next.activeStepIndex = prevIndex + 1
} }
next.stepTransitionDirection = 'Backward' next.stepTransitionDirection = 'Backward'
break 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': { case 'finish': {
next = initialState next = createInitialOnboardingState({
starterPacksStepEnabled: s.screens['suggested-starterpacks'],
findContactsStepEnabled: s.screens['find-contacts'],
})
break break
} }
case 'setInterestsStepResults': { 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 = { const state = {
...next, ...next,
hasPrev: next.activeStep !== 'profile', hasPrev: next.activeStep !== 'profile',
@@ -170,7 +189,6 @@ export function reducer(
logger.debug(`onboarding`, { logger.debug(`onboarding`, {
hasPrev: state.hasPrev, hasPrev: state.hasPrev,
activeStep: state.activeStep, activeStep: state.activeStep,
activeStepIndex: state.activeStepIndex,
interestsStepResults: { interestsStepResults: {
selectedInterests: state.interestsStepResults.selectedInterests, selectedInterests: state.interestsStepResults.selectedInterests,
}, },
@@ -183,3 +201,57 @@ export function reducer(
return state 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 {OUTER_SPACE} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography' 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 {Button, ButtonIcon} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {sizes as iconSizes} from '#/components/icons/common' 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 {Link} from '#/components/Link'
import {Text, type TextProps} from '#/components/Typography' 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 {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
import {AvatarStackWithFetch} from '#/components/AvatarStack' import {AvatarStackWithFetch} from '#/components/AvatarStack'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' 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 {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 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 {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
@@ -89,6 +91,8 @@ export function SettingsScreen({}: Props) {
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const [showAccounts, setShowAccounts] = useState(false) const [showAccounts, setShowAccounts] = useState(false)
const [showDevOptions, setShowDevOptions] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false)
const findContactsEnabled =
useIsFindContactsFeatureEnabledBasedOnGeolocation()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -207,6 +211,16 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans> <Trans>Content and media</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </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 <SettingsList.LinkItem
to="/settings/appearance" to="/settings/appearance"
label={_(msg`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 * as Toast from '#/view/com/util/Toast'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as SettingsList from '../components/SettingsList' 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 findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 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 findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
@@ -256,4 +257,5 @@ function* findProfilesInCache(
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
yield* findAllProfilesInNotifsQueryData(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 page of queryData?.pages) {
for (const item of page.items) { 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 yield item.notification.author
} else if ( } else if (
item.type !== 'starterpack-joined' && item.type !== 'starterpack-joined' &&
+1
View File
@@ -49,6 +49,7 @@ type OtherNotificationType =
| 'like-via-repost' | 'like-via-repost'
| 'repost-via-repost' | 'repost-via-repost'
| 'subscribed-post' | 'subscribed-post'
| 'contact-match'
| 'unknown' | 'unknown'
type FeedNotificationBase = { type FeedNotificationBase = {
+2 -1
View File
@@ -273,7 +273,8 @@ function toKnownType(
notif.reason === 'unverified' || notif.reason === 'unverified' ||
notif.reason === 'like-via-repost' || notif.reason === 'like-via-repost' ||
notif.reason === 'repost-via-repost' || notif.reason === 'repost-via-repost' ||
notif.reason === 'subscribed-post' notif.reason === 'subscribed-post' ||
notif.reason === 'contact-match'
) { ) {
return notif.reason as NotificationType return notif.reason as NotificationType
} }
+12
View File
@@ -10,6 +10,8 @@ export enum Nux {
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
BookmarksAnnouncement = 'BookmarksAnnouncement', BookmarksAnnouncement = 'BookmarksAnnouncement',
FindContactsAnnouncement = 'FindContactsAnnouncement',
FindContactsDismissibleBanner = 'FindContactsDismissibleBanner',
/* /*
* Blocking announcements. New IDs are required for each new announcement. * Blocking announcements. New IDs are required for each new announcement.
@@ -52,6 +54,14 @@ export type AppNux = BaseNux<
id: Nux.BookmarksAnnouncement id: Nux.BookmarksAnnouncement
data: undefined data: undefined
} }
| {
id: Nux.FindContactsAnnouncement
data: undefined
}
| {
id: Nux.FindContactsDismissibleBanner
data: undefined
}
> >
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | 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.AgeAssuranceDismissibleFeedBanner]: undefined,
[Nux.PolicyUpdate202508]: undefined, [Nux.PolicyUpdate202508]: undefined,
[Nux.BookmarksAnnouncement]: undefined, [Nux.BookmarksAnnouncement]: undefined,
[Nux.FindContactsAnnouncement]: undefined,
[Nux.FindContactsDismissibleBanner]: undefined,
} }
@@ -1,11 +1,4 @@
import { import {memo, useCallback, useEffect, useMemo, useState} from 'react'
memo,
type ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import { import {
Animated, Animated,
type GestureResponderEvent, type GestureResponderEvent,
@@ -61,6 +54,7 @@ import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
} from '#/components/icons/Chevron' } 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 {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
@@ -116,6 +110,7 @@ let NotificationFeedItem = ({
break break
} }
case 'follow': case 'follow':
case 'contact-match':
case 'verified': case 'verified':
case 'unverified': { case 'unverified': {
return makeProfileLink(item.notification.author) return makeProfileLink(item.notification.author)
@@ -279,7 +274,7 @@ let NotificationFeedItem = ({
: '' : ''
let a11yLabel = '' let a11yLabel = ''
let notificationContent: ReactElement<any> let notificationContent: React.ReactElement<any>
let icon = ( let icon = (
<HeartIconFilled <HeartIconFilled
size="xl" size="xl"
@@ -373,6 +368,14 @@ let NotificationFeedItem = ({
) )
} }
icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> 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') { } else if (item.type === 'feedgen-like') {
a11yLabel = hasMultipleAuthors a11yLabel = hasMultipleAuthors
? _( ? _(
@@ -438,7 +441,7 @@ let NotificationFeedItem = ({
notificationContent = hasMultipleAuthors ? ( notificationContent = hasMultipleAuthors ? (
<Trans> <Trans>
{firstAuthorLink} and{' '} {firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}> <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Plural <Plural
value={additionalAuthorsCount} value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`} one={`${formattedAuthorsCount} other`}
@@ -463,7 +466,7 @@ let NotificationFeedItem = ({
notificationContent = hasMultipleAuthors ? ( notificationContent = hasMultipleAuthors ? (
<Trans> <Trans>
{firstAuthorLink} and{' '} {firstAuthorLink} and{' '}
<Text style={[pal.text, s.bold]}> <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Plural <Plural
value={additionalAuthorsCount} value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`} one={`${formattedAuthorsCount} other`}
@@ -670,7 +673,9 @@ let NotificationFeedItem = ({
</TimeElapsed> </TimeElapsed>
</Text> </Text>
</ExpandListPressable> </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} /> <FollowBackButton profile={item.notification.author} />
) : null} ) : null}
{item.type === 'post-like' || {item.type === 'post-like' ||
@@ -809,6 +814,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
} }
const isFollowing = profileShadow.viewer.following const isFollowing = profileShadow.viewer.following
const isFollowedBy = profileShadow.viewer.followedBy
const followingLabel = _( const followingLabel = _(
msg({ msg({
message: 'Following', message: 'Following',
@@ -832,14 +838,14 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) {
</Button> </Button>
) : ( ) : (
<Button <Button
label={_(msg`Follow back`)} label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)}
color="primary" color="primary"
size="small" size="small"
style={[a.self_start]} style={[a.self_start]}
onPress={onPressFollow}> onPress={onPressFollow}>
<ButtonIcon icon={PlusIcon} /> <ButtonIcon icon={PlusIcon} />
<ButtonText> <ButtonText>
<Trans>Follow back</Trans> {isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>}
</ButtonText> </ButtonText>
</Button> </Button>
)} )}
+2
View File
@@ -12,6 +12,7 @@ import {isWeb} from '#/platform/detection'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX'
import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {List} from '../util/List' import {List} from '../util/List'
@@ -208,6 +209,7 @@ export function ProfileFollows({name}: {name: string}) {
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
onItemSeen={onItemSeen} onItemSeen={onItemSeen}
ListHeaderComponent={<FindContactsBannerNUX />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} 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 {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 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 {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import { import {
+1 -1
View File
@@ -9,7 +9,7 @@ import {useLingui} from '@lingui/react'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' 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 * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
+1 -1
View File
@@ -13,7 +13,7 @@ import {
Text as AdmonitionText, Text as AdmonitionText,
} from '#/components/Admonition' } from '#/components/Admonition'
import {ButtonIcon, ButtonText} from '#/components/Button' 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 {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {H1} from '#/components/Typography' import {H1} from '#/components/Typography'
+1 -1
View File
@@ -1,7 +1,7 @@
import {View} from 'react-native' import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf' 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 * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
// import {useDialogStateControlContext} from '#/state/dialogs' // import {useDialogStateControlContext} from '#/state/dialogs'
+1 -1
View File
@@ -41,7 +41,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home, HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen' } from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 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 { import {
Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -44,7 +44,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home, HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen' } from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 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 { import {
Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -32,7 +32,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home, HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen' } from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 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 { import {
Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
View File
@@ -56,7 +56,7 @@ import {
HomeOpen_Stoke2_Corner0_Rounded as Home, HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen' } from '#/components/icons/HomeOpen'
import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 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 { import {
Message_Stroke2_Corner0_Rounded as Message, Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled, Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+480 -502
View File
File diff suppressed because it is too large Load Diff