Bluesky app fork with some witchin' additions 💫

[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>

authored by samuel.fm surfdude29 Eric Bailey and committed by GitHub ad7d49ef d3a6db45

Changed files
+4937 -1110
assets
bskyweb
cmd
bskyweb
docs
patches
src
components
lib
logger
screens
Messages
Onboarding
StepFindContacts
StepFindContactsIntro
StepFinished
StepInterests
StepProfile
StepSuggestedAccounts
StepSuggestedStarterpacks
PostThread
components
Search
components
Settings
state
view
+7
app.config.js
··· 389 389 ], 390 390 ['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}], 391 391 ['expo-location'], 392 + [ 393 + 'expo-contacts', 394 + { 395 + contactsPermission: 396 + 'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.', 397 + }, 398 + ], 392 399 ].filter(Boolean), 393 400 extra: { 394 401 eas: {
+1
assets/icons/arrowRotateClockwise_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/contacts_filled_corner2_rounded.svg
··· 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>
+1
assets/icons/contacts_stroke2_corner2_rounded.svg
··· 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>
+1
assets/icons/magnifyingGlassX_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/magnifyingGlassX_stroke2_corner0_rounded_large.svg
··· 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>
+1
assets/icons/personX_stroke2_corner0_rounded_large.svg
··· 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>
assets/images/find_friends_illustration.webp

This is a binary file and will not be displayed.

assets/images/find_friends_illustration_small.webp

This is a binary file and will not be displayed.

-1
bskyweb/cmd/bskyweb/server.go
··· 308 308 e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric) 309 309 e.GET("/settings/notifications/activity", server.WebGeneric) 310 310 e.GET("/settings/notifications/miscellaneous", server.WebGeneric) 311 - e.GET("/settings/app-icon", server.WebGeneric) 312 311 e.GET("/sys/debug", server.WebGeneric) 313 312 e.GET("/sys/debug-mod", server.WebGeneric) 314 313 e.GET("/sys/log", server.WebGeneric)
+2
docs/build.md
··· 111 111 - Start the docker daemon (on MacOS this entails starting the Docker Desktop app) 112 112 - Launch a Postgres database on port 5432 113 113 - `cd packages/dev-env && pnpm start` 114 + 115 + Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start` 114 116 115 117 Then, when logging in or creating an account, point it to the localhost port of the devserver. 116 118
+37 -34
package.json
··· 72 72 "icons:optimize": "svgo -f ./assets/icons" 73 73 }, 74 74 "dependencies": { 75 - "@atproto/api": "^0.18.6", 75 + "@atproto/api": "^0.18.8", 76 76 "@bitdrift/react-native": "^0.6.8", 77 77 "@braintree/sanitize-url": "^6.0.2", 78 - "@bsky.app/alf": "^0.1.5", 78 + "@bsky.app/alf": "^0.1.6", 79 79 "@bsky.app/react-native-mmkv": "2.12.5", 80 80 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 81 81 "@emoji-mart/react": "^1.1.1", ··· 132 132 "emoji-mart": "^5.5.2", 133 133 "emoji-regex": "^10.4.0", 134 134 "eventemitter3": "^5.0.1", 135 - "expo": "^54.0.22", 136 - "expo-application": "~7.0.7", 137 - "expo-blur": "~15.0.7", 138 - "expo-build-properties": "~1.0.9", 139 - "expo-camera": "~17.0.9", 140 - "expo-clipboard": "~8.0.7", 141 - "expo-dev-client": "~6.0.16", 142 - "expo-device": "~8.0.9", 143 - "expo-file-system": "~19.0.17", 144 - "expo-font": "~14.0.9", 145 - "expo-haptics": "~15.0.7", 146 - "expo-image": "~3.0.10", 135 + "expo": "^54.0.27", 136 + "expo-application": "~7.0.8", 137 + "expo-blur": "~15.0.8", 138 + "expo-build-properties": "~1.0.10", 139 + "expo-camera": "~17.0.10", 140 + "expo-clipboard": "~8.0.8", 141 + "expo-contacts": "^15.0.10", 142 + "expo-dev-client": "~6.0.20", 143 + "expo-device": "~8.0.10", 144 + "expo-file-system": "~19.0.20", 145 + "expo-font": "~14.0.10", 146 + "expo-haptics": "~15.0.8", 147 + "expo-image": "~3.0.11", 147 148 "expo-image-crop-tool": "^0.4.0", 148 - "expo-image-manipulator": "~14.0.7", 149 - "expo-image-picker": "~17.0.8", 150 - "expo-intent-launcher": "~13.0.7", 151 - "expo-keep-awake": "^15.0.7", 152 - "expo-linear-gradient": "~15.0.7", 153 - "expo-linking": "~8.0.8", 154 - "expo-localization": "~17.0.7", 155 - "expo-location": "~19.0.7", 156 - "expo-media-library": "~18.2.0", 157 - "expo-notifications": "~0.32.12", 158 - "expo-screen-orientation": "~9.0.7", 159 - "expo-sharing": "~14.0.7", 160 - "expo-splash-screen": "~31.0.10", 161 - "expo-system-ui": "~6.0.8", 162 - "expo-task-manager": "~14.0.8", 163 - "expo-updates": "~29.0.12", 164 - "expo-video": "~3.0.13", 165 - "expo-web-browser": "~15.0.9", 149 + "expo-image-manipulator": "~14.0.8", 150 + "expo-image-picker": "~17.0.9", 151 + "expo-intent-launcher": "~13.0.8", 152 + "expo-keep-awake": "~15.0.8", 153 + "expo-linear-gradient": "~15.0.8", 154 + "expo-linking": "~8.0.10", 155 + "expo-localization": "~17.0.8", 156 + "expo-location": "~19.0.8", 157 + "expo-media-library": "~18.2.1", 158 + "expo-notifications": "~0.32.14", 159 + "expo-screen-orientation": "~9.0.8", 160 + "expo-sharing": "~14.0.8", 161 + "expo-sms": "^14.0.7", 162 + "expo-splash-screen": "~31.0.12", 163 + "expo-system-ui": "~6.0.9", 164 + "expo-task-manager": "~14.0.9", 165 + "expo-updates": "~29.0.14", 166 + "expo-video": "~3.0.15", 167 + "expo-web-browser": "~15.0.10", 166 168 "fast-text-encoding": "^1.0.6", 167 169 "history": "^5.3.0", 168 170 "hls.js": "^1.6.2", 169 171 "js-sha256": "^0.9.0", 170 172 "jwt-decode": "^4.0.0", 171 173 "lande": "^1.0.10", 174 + "libphonenumber-js": "^1.12.31", 172 175 "lodash.chunk": "^4.2.0", 173 176 "lodash.debounce": "^4.0.8", 174 177 "lodash.isequal": "^4.5.0", ··· 222 225 "zod": "^3.20.2" 223 226 }, 224 227 "devDependencies": { 225 - "@atproto/dev-env": "^0.3.193", 228 + "@atproto/dev-env": "^0.3.196", 226 229 "@babel/core": "^7.26.0", 227 230 "@babel/preset-env": "^7.26.0", 228 231 "@babel/runtime": "^7.26.0", ··· 264 267 "husky": "^8.0.3", 265 268 "is-ci": "^3.0.1", 266 269 "jest": "^29.7.0", 267 - "jest-expo": "^54.0.14", 270 + "jest-expo": "~54.0.14", 268 271 "jest-junit": "^16.0.0", 269 272 "lint-staged": "^13.2.3", 270 273 "lockfile-lint": "^4.14.0",
patches/expo-haptics+15.0.7.patch patches/expo-haptics+15.0.8.patch
patches/expo-haptics+15.0.7.patch.md patches/expo-haptics+15.0.8.patch.md
patches/expo-media-library+18.2.0.patch patches/expo-media-library+18.2.1.patch
patches/expo-modules-core+3.0.24.patch patches/expo-modules-core+3.0.28.patch
patches/expo-modules-core+3.0.24.patch.md patches/expo-modules-core+3.0.28.patch.md
patches/expo-notifications+0.32.12.patch patches/expo-notifications+0.32.14.patch
patches/expo-notifications+0.32.12.patch.md patches/expo-notifications+0.32.14.patch.md
-47
patches/expo-updates+29.0.12.patch
··· 1 - 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 2 - index e9a8e3d..3c684e0 100644 3 - --- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt 4 - +++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt 5 - @@ -296,14 +296,13 @@ class LoaderTask( 6 - ) { 7 - try { 8 - val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory) 9 - - val result = embeddedLoader.load { updateResponse -> 10 - + embeddedLoader.load { _ -> 11 - Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) 12 - } 13 - - launcher.launch(database) 14 - } catch (e: Exception) { 15 - logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown) 16 - - launcher.launch(database) 17 - } 18 - + launcher.launch(database) 19 - } else { 20 - launcher.launch(database) 21 - } 22 - diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 23 - index 68086bd..78c7761 100644 24 - --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 25 - +++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 26 - @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update { 27 - status = UpdateStatus.StatusPending 28 - } 29 - 30 - + // Instead of relying on various hacks to get the correct format for the specific 31 - + // platform on the backend, we can just add this little patch.. 32 - + let dateFormatter = DateFormatter() 33 - + dateFormatter.locale = Locale(identifier: "en_US_POSIX") 34 - + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" 35 - + let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)! 36 - + 37 - return Update( 38 - manifest: manifest, 39 - config: config, 40 - database: database, 41 - updateId: uuid, 42 - scopeKey: config.scopeKey, 43 - - commitTime: RCTConvert.nsDate(commitTime), 44 - + commitTime: date, 45 - runtimeVersion: runtimeVersion, 46 - keep: true, 47 - status: status,
patches/expo-updates+29.0.12.patch.md patches/expo-updates+29.0.15.patch.md
+26
patches/expo-updates+29.0.15.patch
··· 1 + diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 2 + index 68086bd..78c7761 100644 3 + --- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 4 + +++ b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift 5 + @@ -78,13 +78,20 @@ public final class ExpoUpdatesUpdate: Update { 6 + status = UpdateStatus.StatusPending 7 + } 8 + 9 + + // Instead of relying on various hacks to get the correct format for the specific 10 + + // platform on the backend, we can just add this little patch.. 11 + + let dateFormatter = DateFormatter() 12 + + dateFormatter.locale = Locale(identifier: "en_US_POSIX") 13 + + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" 14 + + let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)! 15 + + 16 + return Update( 17 + manifest: manifest, 18 + config: config, 19 + database: database, 20 + updateId: uuid, 21 + scopeKey: config.scopeKey, 22 + - commitTime: RCTConvert.nsDate(commitTime), 23 + + commitTime: date, 24 + runtimeVersion: runtimeVersion, 25 + keep: true, 26 + status: status,
+23 -4
src/Navigation.tsx
··· 18 18 } from '@react-navigation/native' 19 19 20 20 import {timeout} from '#/lib/async/timeout' 21 + import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' 21 22 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 23 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 22 24 import { 23 25 getNotificationPayload, 24 26 type NotificationPayload, ··· 45 47 import {isNative, isWeb} from '#/platform/detection' 46 48 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 47 49 import {useSession} from '#/state/session' 50 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 48 51 import { 49 52 shouldRequestEmailConfirmation, 50 53 snoozeEmailConfirmationPrompt, 51 54 } from '#/state/shell/reminders' 55 + import {useCloseAllActiveElements} from '#/state/util' 52 56 import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' 53 57 import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' 54 58 import {DebugModScreen} from '#/view/screens/DebugMod' ··· 71 75 import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' 72 76 import {BookmarksScreen} from '#/screens/Bookmarks' 73 77 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' 78 + import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen' 74 79 import HashtagScreen from '#/screens/Hashtag' 75 80 import {LogScreen} from '#/screens/Log' 76 81 import {MessagesScreen} from '#/screens/Messages/ChatList' ··· 102 107 import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' 103 108 import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings' 104 109 import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' 110 + import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings' 105 111 import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' 106 112 import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings' 107 113 import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' ··· 134 140 } from '#/components/dialogs/EmailDialog' 135 141 import {router} from '#/routes' 136 142 import {Referrer} from '../modules/expo-bluesky-swiss-army' 137 - import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher' 138 - import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback' 139 - import {useLoggedOutViewControls} from './state/shell/logged-out' 140 - import {useCloseAllActiveElements} from './state/util' 141 143 142 144 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 143 145 ··· 417 419 }} 418 420 /> 419 421 <Stack.Screen 422 + name="FindContactsSettings" 423 + getComponent={() => FindContactsSettingsScreen} 424 + options={{ 425 + title: title(msg`Find Contacts`), 426 + requireAuth: true, 427 + }} 428 + /> 429 + <Stack.Screen 420 430 name="NotificationSettings" 421 431 getComponent={() => NotificationSettingsScreen} 422 432 options={{title: title(msg`Notification settings`), requireAuth: true}} ··· 607 617 options={{ 608 618 title: title(msg`Saved Posts`), 609 619 requireAuth: true, 620 + }} 621 + /> 622 + <Stack.Screen 623 + name="FindContactsFlow" 624 + getComponent={() => FindContactsFlowScreen} 625 + options={{ 626 + title: title(msg`Find Contacts`), 627 + requireAuth: true, 628 + gestureEnabled: false, 610 629 }} 611 630 /> 612 631 </>
+4 -3
src/components/InternationalPhoneCodeSelect.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import { 7 + type CountryCode, 7 8 getDefaultCountry, 8 9 INTERNATIONAL_TELEPHONE_CODES, 9 10 } from '#/lib/international-telephone-codes' ··· 23 24 value, 24 25 onChange, 25 26 }: { 26 - value?: string 27 - onChange: (value: string) => void 27 + value?: CountryCode 28 + onChange: (value: CountryCode) => void 28 29 }) { 29 30 const {_, i18n} = useLingui() 30 31 const location = useGeolocation() ··· 62 63 }, [value, items]) 63 64 64 65 return ( 65 - <Select.Root value={value} onValueChange={onChange}> 66 + <Select.Root value={value} onValueChange={onChange as (v: string) => void}> 66 67 <Select.Trigger label={_(msg`Select telephone code`)}> 67 68 <Select.ValueText placeholder="+..." webOverrideValue={selected}> 68 69 {selected => (
+3 -2
src/components/Layout/Header/index.tsx
··· 38 38 }: { 39 39 children: React.ReactNode 40 40 noBottomBorder?: boolean 41 - headerRef?: React.MutableRefObject<View | null> 41 + headerRef?: React.RefObject<View | null> 42 42 sticky?: boolean 43 43 }) { 44 44 const t = useTheme() ··· 129 129 size="small" 130 130 variant="ghost" 131 131 color="secondary" 132 + shape="round" 132 133 onPress={onPressBack} 133 134 hitSlop={HITSLOP_30} 134 135 style={[ ··· 183 184 <Text 184 185 style={[ 185 186 a.text_lg, 186 - a.font_bold, 187 + a.font_semi_bold, 187 188 a.leading_tight, 188 189 isIOS && align === 'platform' && a.text_center, 189 190 gtMobile && a.text_xl,
+1 -1
src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx
··· 4 4 5 5 import {atoms as a, useTheme} from '#/alf' 6 6 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 7 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotateCounterClockwise' 7 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate' 8 8 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 9 9 import {Text as TypoText} from '#/components/Typography' 10 10
+8 -2
src/components/ProfileCard.tsx
··· 330 330 ) 331 331 } 332 332 333 - export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) { 333 + export function Handle({ 334 + profile, 335 + textStyle, 336 + }: { 337 + profile: bsky.profile.AnyProfileView 338 + textStyle?: StyleProp<TextStyle> 339 + }) { 334 340 const t = useTheme() 335 341 const handle = sanitizeHandle(profile.handle, '@') 336 342 337 343 return ( 338 344 <Text 339 345 emoji 340 - style={[a.leading_snug, t.atoms.text_contrast_medium]} 346 + style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]} 341 347 numberOfLines={1}> 342 348 {handle} 343 349 </Text>
+1 -1
src/components/ProgressGuide/FollowDialog.tsx
··· 31 31 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32 32 import * as Dialog from '#/components/Dialog' 33 33 import {useInteractionState} from '#/components/hooks/useInteractionState' 34 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 34 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 35 35 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 36 36 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 37 37 import {boostInterests, InterestTabs} from '#/components/InterestTabs'
+1 -1
src/components/RichTextTag.tsx
··· 12 12 useRemoveMutedWordsMutation, 13 13 useUpsertMutedWordsMutation, 14 14 } from '#/state/queries/preferences' 15 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 15 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 16 16 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 17 17 import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 18 18 import {
+4 -5
src/components/SearchError.tsx
··· 1 1 import {View} from 'react-native' 2 2 3 - import {usePalette} from '#/lib/hooks/usePalette' 4 - import {atoms as a, useBreakpoints} from '#/alf' 3 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 4 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 5 5 import * as Layout from '#/components/Layout' 6 6 import {Text} from '#/components/Typography' 7 - import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times' 8 7 9 8 export function SearchError({ 10 9 title, ··· 14 13 children?: React.ReactNode 15 14 }) { 16 15 const {gtMobile} = useBreakpoints() 17 - const pal = usePalette('default') 16 + const t = useTheme() 18 17 19 18 return ( 20 19 <Layout.Content> ··· 27 26 paddingVertical: 150, 28 27 }, 29 28 ]}> 30 - <TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} /> 29 + <XIcon width={32} style={[t.atoms.text_contrast_low]} /> 31 30 <View 32 31 style={[ 33 32 a.align_center,
+107
src/components/contacts/FindContactsBannerNUX.tsx
··· 1 + import {useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {HITSLOP_10} from '#/lib/constants' 9 + import {logger} from '#/logger' 10 + import {isWeb} from '#/platform/detection' 11 + import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' 12 + import {atoms as a, useTheme} from '#/alf' 13 + import {Button} from '#/components/Button' 14 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 15 + import {Text} from '#/components/Typography' 16 + import {Link} from '../Link' 17 + 18 + export function FindContactsBannerNUX() { 19 + const t = useTheme() 20 + const {_} = useLingui() 21 + const {visible, close} = useInternalState() 22 + 23 + if (!visible) return null 24 + 25 + return ( 26 + <View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}> 27 + <View style={a.w_full}> 28 + <Link 29 + to={{screen: 'FindContactsFlow'}} 30 + label={_(msg`Import contacts to find your friends`)} 31 + onPress={() => { 32 + logger.metric('contacts:nux:bannerPressed', {}) 33 + }} 34 + style={[ 35 + a.w_full, 36 + a.rounded_xl, 37 + a.curve_continuous, 38 + a.overflow_hidden, 39 + ]}> 40 + <LinearGradient 41 + colors={[t.palette.primary_200, t.palette.primary_50]} 42 + start={{x: 0, y: 0.5}} 43 + end={{x: 1, y: 0.5}} 44 + style={[ 45 + a.w_full, 46 + a.h_full, 47 + a.flex_row, 48 + a.align_center, 49 + a.gap_lg, 50 + a.pl_lg, 51 + ]}> 52 + <Image 53 + source={require('../../../assets/images/find_friends_illustration_small.webp')} 54 + accessibilityIgnoresInvertColors 55 + style={[ 56 + {height: 70, aspectRatio: 573 / 286}, 57 + a.self_end, 58 + a.mt_sm, 59 + ]} 60 + /> 61 + <View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}> 62 + <Text 63 + style={[ 64 + a.text_md, 65 + a.font_bold, 66 + {color: t.palette.primary_900}, 67 + ]}> 68 + <Trans>Import contacts to find your friends</Trans> 69 + </Text> 70 + </View> 71 + </LinearGradient> 72 + </Link> 73 + <Button 74 + label={_(msg`Dismiss banner`)} 75 + hitSlop={HITSLOP_10} 76 + onPress={close} 77 + style={[a.absolute, {top: 14, right: 14}]} 78 + hoverStyle={[a.bg_transparent, {opacity: 0.5}]}> 79 + <XIcon size="xs" style={[t.atoms.text_contrast_low]} /> 80 + </Button> 81 + </View> 82 + </View> 83 + ) 84 + } 85 + function useInternalState() { 86 + const {nux} = useNux(Nux.FindContactsDismissibleBanner) 87 + const {mutate: save, variables} = useSaveNux() 88 + const hidden = !!variables 89 + 90 + const visible = useMemo(() => { 91 + if (isWeb) return false 92 + if (hidden) return false 93 + if (nux && nux.completed) return false 94 + return true 95 + }, [hidden, nux]) 96 + 97 + const close = () => { 98 + save({ 99 + id: Nux.FindContactsDismissibleBanner, 100 + completed: true, 101 + data: undefined, 102 + }) 103 + logger.metric('contacts:nux:bannerDismissed', {}) 104 + } 105 + 106 + return {visible, close} 107 + }
+56
src/components/contacts/FindContactsFlow.tsx
··· 1 + import {GetContacts} from './screens/GetContacts' 2 + import {PhoneInput} from './screens/PhoneInput' 3 + import {VerifyNumber} from './screens/VerifyNumber' 4 + import {ViewMatches} from './screens/ViewMatches' 5 + import {type Action, FindContactsGoBackContext, type State} from './state' 6 + 7 + export function FindContactsFlow({ 8 + state, 9 + dispatch, 10 + onBack, 11 + onCancel, 12 + context = 'Standalone', 13 + }: { 14 + state: State 15 + dispatch: React.ActionDispatch<[Action]> 16 + onBack?: () => void 17 + onCancel: () => void 18 + context: 'Onboarding' | 'Standalone' 19 + }) { 20 + return ( 21 + <FindContactsGoBackContext value={onBack}> 22 + {state.step === '1: phone input' && ( 23 + <PhoneInput 24 + state={state} 25 + dispatch={dispatch} 26 + context={context} 27 + onSkip={onCancel} 28 + /> 29 + )} 30 + {state.step === '2: verify number' && ( 31 + <VerifyNumber 32 + state={state} 33 + dispatch={dispatch} 34 + context={context} 35 + onSkip={onCancel} 36 + /> 37 + )} 38 + {state.step === '3: get contacts' && ( 39 + <GetContacts 40 + state={state} 41 + dispatch={dispatch} 42 + onCancel={onCancel} 43 + context={context} 44 + /> 45 + )} 46 + {state.step === '4: view matches' && ( 47 + <ViewMatches 48 + state={state} 49 + dispatch={dispatch} 50 + context={context} 51 + onNext={onCancel} 52 + /> 53 + )} 54 + </FindContactsGoBackContext> 55 + ) 56 + }
+3
src/components/contacts/FindContactsFlow.web.tsx
··· 1 + export function FindContactsFlow() { 2 + throw new Error('FindContactsFlow is not available on web') 3 + }
+33
src/components/contacts/components/HeroImage.tsx
··· 1 + import {View} from 'react-native' 2 + import {Image} from 'expo-image' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {atoms as a, useTheme} from '#/alf' 7 + 8 + export function ContactsHeroImage() { 9 + const t = useTheme() 10 + const {_} = useLingui() 11 + 12 + return ( 13 + <View 14 + style={[ 15 + a.w_full, 16 + a.pl_3xl, 17 + a.pr_2xl, 18 + a.pt_4xl, 19 + a.pb_3xl, 20 + a.rounded_lg, 21 + {backgroundColor: t.palette.primary_50}, 22 + ]}> 23 + <Image 24 + source={require('../../../../assets/images/find_friends_illustration.webp')} 25 + accessibilityIgnoresInvertColors 26 + style={[a.w_full, {aspectRatio: 1278 / 661}]} 27 + alt={_( 28 + msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, 29 + )} 30 + /> 31 + </View> 32 + ) 33 + }
+85
src/components/contacts/components/InviteInfo.tsx
··· 1 + import {type StyleProp, type TextStyle} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {HITSLOP_20} from '#/lib/constants' 6 + import {android, atoms as a} from '#/alf' 7 + import {Button, ButtonText} from '#/components/Button' 8 + import * as Dialog from '#/components/Dialog' 9 + import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo' 10 + import {Text} from '#/components/Typography' 11 + 12 + export function InviteInfo({ 13 + iconStyle, 14 + iconOffset = 0, 15 + }: { 16 + iconStyle: StyleProp<TextStyle> 17 + /** 18 + * Adjust the vertical position of the icon via `translateY` 19 + * 20 + * @platform android 21 + */ 22 + iconOffset?: number 23 + }) { 24 + const {_} = useLingui() 25 + const control = Dialog.useDialogControl() 26 + 27 + const style = [a.text_md, a.leading_snug, a.mt_xs] 28 + 29 + return ( 30 + <> 31 + <Button 32 + label={_(msg`Learn more about how inviting friends works`)} 33 + onPress={control.open} 34 + hitSlop={HITSLOP_20} 35 + style={android({transform: [{translateY: iconOffset}]})}> 36 + <InfoIcon style={iconStyle} size="sm" /> 37 + </Button> 38 + 39 + <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 40 + <Dialog.Handle /> 41 + <Dialog.ScrollableInner label={_(msg`Invite Friends`)}> 42 + <Text style={[a.text_2xl, a.font_bold]}> 43 + <Trans>Invite Friends</Trans> 44 + </Text> 45 + <Text style={style}> 46 + <Trans> 47 + It looks like some of your contacts have not tried to find you 48 + here yet. You can personally invite them by customizing a draft 49 + message we will provide. 50 + </Trans> 51 + </Text> 52 + <Text style={[style, a.font_medium, a.mt_lg]}> 53 + <Trans>How it works:</Trans> 54 + </Text> 55 + <Text style={style}> 56 + &bull; <Trans>Choose who to invite</Trans> 57 + </Text> 58 + <Text style={style}> 59 + &bull; <Trans>Personalize the message</Trans> 60 + </Text> 61 + <Text style={style}> 62 + &bull; <Trans>Send the message from your phone</Trans> 63 + </Text> 64 + <Text style={style}> 65 + &bull;{' '} 66 + <Trans> 67 + We don't store your friends' phone numbers or send any messages 68 + </Trans> 69 + </Text> 70 + 71 + <Button 72 + label={_(msg`Done`)} 73 + onPress={() => control.close()} 74 + size="large" 75 + color="primary" 76 + style={[a.mt_2xl]}> 77 + <ButtonText> 78 + <Trans>Done</Trans> 79 + </ButtonText> 80 + </Button> 81 + </Dialog.ScrollableInner> 82 + </Dialog.Outer> 83 + </> 84 + ) 85 + }
+143
src/components/contacts/components/OTPInput.tsx
··· 1 + import {useRef, useState} from 'react' 2 + import { 3 + Pressable, 4 + TextInput, 5 + type TextInputSelectionChangeEvent, 6 + View, 7 + } from 'react-native' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {mergeRefs} from '#/lib/merge-refs' 12 + import {isAndroid, isIOS} from '#/platform/detection' 13 + import {atoms as a, ios, platform, useTheme} from '#/alf' 14 + import {useInteractionState} from '#/components/hooks/useInteractionState' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function OTPInput({ 18 + label, 19 + value, 20 + onChange, 21 + ref, 22 + numberOfDigits = 6, 23 + onComplete, 24 + }: { 25 + label: string 26 + value: string 27 + onChange: (text: string) => void 28 + ref?: React.Ref<TextInput> 29 + numberOfDigits?: number 30 + onComplete?: (code: string) => void 31 + }) { 32 + const t = useTheme() 33 + const {_} = useLingui() 34 + const innerRef = useRef<TextInput>(null) 35 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 36 + const [selection, setSelection] = useState({start: 0, end: 0}) 37 + 38 + const onChangeText = (text: string) => { 39 + // only numbers 40 + text = text.replace(/[^0-9]/g, '') 41 + text = text.slice(0, numberOfDigits) 42 + onChange(text) 43 + if (text.length === numberOfDigits) { 44 + onComplete?.(text) 45 + innerRef.current?.blur() 46 + } 47 + } 48 + 49 + const onSelectionChange = (evt: TextInputSelectionChangeEvent) => { 50 + setSelection(evt.nativeEvent.selection) 51 + } 52 + 53 + return ( 54 + <Pressable 55 + accessibilityLabel={_(msg`Focus code input`)} 56 + accessibilityRole="button" 57 + accessibilityHint="" 58 + style={[a.w_full, a.relative]} 59 + onPress={() => { 60 + innerRef.current?.focus() 61 + innerRef.current?.clear() 62 + }}> 63 + <View style={[a.w_full, a.flex_row, a.gap_sm]}> 64 + {[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => { 65 + const selected = focused 66 + ? selection.start === selection.end 67 + ? selection.start === index 68 + : index >= selection.start && index < selection.end 69 + : false 70 + 71 + return ( 72 + <View 73 + key={index} 74 + style={[ 75 + a.flex_1, 76 + a.align_center, 77 + a.justify_center, 78 + t.atoms.bg_contrast_50, 79 + { 80 + height: 64, 81 + borderWidth: 1, 82 + borderRadius: 10, 83 + borderColor: selected 84 + ? t.palette.primary_500 85 + : t.atoms.bg_contrast_50.backgroundColor, 86 + }, 87 + ]}> 88 + <Text style={[a.text_2xl, a.text_center, a.font_bold]}> 89 + {digit} 90 + </Text> 91 + </View> 92 + ) 93 + })} 94 + </View> 95 + <TextInput 96 + // SMS autofill is borked on iOS if you open the keyboard immediately -sfn 97 + onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))} 98 + autoFocus={isAndroid} 99 + accessible 100 + accessibilityLabel={label} 101 + accessibilityHint="" 102 + accessibilityRole="text" 103 + ref={mergeRefs(ref ? [ref, innerRef] : [innerRef])} 104 + value={value} 105 + onChangeText={onChangeText} 106 + onSelectionChange={onSelectionChange} 107 + keyboardAppearance={t.scheme} 108 + inputMode="numeric" 109 + keyboardType="number-pad" 110 + textContentType="oneTimeCode" 111 + autoComplete={platform({ 112 + android: 'sms-otp', 113 + ios: 'one-time-code', 114 + })} 115 + onFocus={onFocus} 116 + onBlur={onBlur} 117 + maxLength={numberOfDigits} 118 + style={[ 119 + a.absolute, 120 + a.inset_0, 121 + // roughly vibe align the characters 122 + // with the visible ones so that 123 + // moving the caret via long press 124 + // still kinda sorta works 125 + { 126 + fontVariant: ['tabular-nums'], 127 + textAlignVertical: 'center', 128 + letterSpacing: 24, 129 + fontSize: 60, 130 + paddingLeft: 6, 131 + }, 132 + platform({ 133 + // completely transparent inputs on iOS cannot be pasted into 134 + ios: {opacity: 0.02, color: 'transparent'}, 135 + android: {opacity: 0}, 136 + }), 137 + ]} 138 + caretHidden={isIOS} 139 + clearTextOnFocus 140 + /> 141 + </Pressable> 142 + ) 143 + }
+100
src/components/contacts/contacts.ts
··· 1 + import {type AppBskyContactDefs} from '@atproto/api' 2 + 3 + import {type CountryCode} from '#/lib/international-telephone-codes' 4 + import {normalizePhoneNumber} from './phone-number' 5 + import {type Contact, type Match} from './state' 6 + 7 + /** 8 + * Filters out contacts that do not have any associated phone numbers, 9 + * as well as businesses 10 + */ 11 + export function contactsWithPhoneNumbersOnly(contacts: Contact[]) { 12 + return contacts.filter( 13 + contact => 14 + contact.phoneNumbers && 15 + contact.phoneNumbers.length > 0 && 16 + contact.contactType !== 'company', 17 + ) 18 + } 19 + 20 + /** 21 + * Takes the raw contact book and returns a plain list of numbers in E.164 format, along 22 + * with a mapping to retrieve the contact ID when we get the results back. 23 + * 24 + * `countryCode` is used as a fallback for local numbers that don't have a country code associated with them. 25 + * I'm making the assumption that most local numbers in someone's phone book will be the same as theirs. 26 + */ 27 + export function normalizeContactBook( 28 + contacts: Contact[], 29 + countryCode: CountryCode, 30 + ownNumber: string, 31 + ): { 32 + phoneNumbers: string[] 33 + indexToContactId: Map<number, Contact['id']> 34 + } { 35 + const phoneNumbers: string[] = [] 36 + const indexToContactId = new Map<number, Contact['id']>() 37 + 38 + for (const contact of contacts) { 39 + for (const number of contact.phoneNumbers ?? []) { 40 + let rawNumber: string 41 + 42 + if (number.number) { 43 + rawNumber = number.number 44 + } else if (number.digits) { 45 + rawNumber = number.digits 46 + } else { 47 + continue 48 + } 49 + 50 + const normalized = normalizePhoneNumber( 51 + rawNumber, 52 + number.countryCode, 53 + countryCode, 54 + ) 55 + if (normalized === null) continue 56 + 57 + // skip if it's your own number 58 + if (normalized === ownNumber) continue 59 + 60 + phoneNumbers.push(normalized) 61 + indexToContactId.set(phoneNumbers.length - 1, contact.id) 62 + } 63 + } 64 + 65 + return { 66 + phoneNumbers, 67 + indexToContactId, 68 + } 69 + } 70 + 71 + export function filterMatchedNumbers( 72 + contacts: Contact[], 73 + results: AppBskyContactDefs.MatchAndContactIndex[], 74 + mapping: Map<number, Contact['id']>, 75 + ) { 76 + const filteredIds = new Set<Contact['id']>() 77 + 78 + for (const result of results) { 79 + const id = mapping.get(result.contactIndex) 80 + if (id !== undefined) { 81 + filteredIds.add(id) 82 + } 83 + } 84 + 85 + return contacts.filter(contact => !filteredIds.has(contact.id)) 86 + } 87 + 88 + export function getMatchedContacts( 89 + contacts: Contact[], 90 + results: AppBskyContactDefs.MatchAndContactIndex[], 91 + mapping: Map<number, Contact['id']>, 92 + ): Array<Match> { 93 + const contactsById = new Map(contacts.map(c => [c.id, c])) 94 + 95 + return results.map(result => { 96 + const id = mapping.get(result.contactIndex) 97 + const contact = id !== undefined ? contactsById.get(id) : undefined 98 + return {profile: result.match, contact} 99 + }) 100 + }
+37
src/components/contacts/country-allowlist.ts
··· 1 + import {type CountryCode} from '#/lib/international-telephone-codes' 2 + import {IS_DEV} from '#/env' 3 + import {useGeolocation} from '#/geolocation' 4 + 5 + const FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST = [ 6 + 'US', 7 + 'GB', 8 + 'JP', 9 + 'CA', 10 + 'DE', 11 + 'FR', 12 + 'ES', 13 + 'BR', 14 + 'KR', 15 + 'NL', 16 + 'AU', 17 + 'SE', 18 + 'IT', 19 + ] satisfies CountryCode[] as string[] 20 + 21 + export function isFindContactsFeatureEnabled(countryCode: string): boolean { 22 + return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes( 23 + countryCode.toUpperCase(), 24 + ) 25 + } 26 + 27 + export function useIsFindContactsFeatureEnabledBasedOnGeolocation() { 28 + const location = useGeolocation() 29 + 30 + if (IS_DEV) return true 31 + 32 + // they can try, by they'll need a phone number 33 + // from one of the allowlisted countries 34 + if (!location.countryCode) return true 35 + 36 + return isFindContactsFeatureEnabled(location.countryCode) 37 + }
+162
src/components/contacts/phone-number.ts
··· 1 + import {t} from '@lingui/macro' 2 + import { 3 + isSupportedCountry, 4 + ParseError, 5 + parsePhoneNumber, 6 + parsePhoneNumberWithError, 7 + type PhoneNumber, 8 + } from 'libphonenumber-js/max' 9 + 10 + import {type CountryCode} from '#/lib/international-telephone-codes' 11 + 12 + /** 13 + * Intended for after the user has finished inputting their phone number. 14 + */ 15 + export function processPhoneNumber( 16 + number: string, 17 + country: CountryCode, 18 + ): 19 + | { 20 + valid: true 21 + formatted: string 22 + countryCode: CountryCode 23 + } 24 + | { 25 + valid: false 26 + reason?: string 27 + } { 28 + try { 29 + const phoneNumber = parsePhoneNumberWithError(number, { 30 + defaultCountry: country, 31 + }) 32 + if (!phoneNumber.isValid()) { 33 + return {valid: false, reason: t`Invalid phone number`} 34 + } 35 + const type = phoneNumber.getType() 36 + if ( 37 + type !== 'MOBILE' && 38 + type !== 'FIXED_LINE_OR_MOBILE' && 39 + type !== 'PERSONAL_NUMBER' 40 + ) { 41 + return { 42 + valid: false, 43 + reason: t`Number should be a mobile number`, 44 + } 45 + } 46 + let countryCode = country 47 + if (phoneNumber.country && phoneNumber.country !== country) { 48 + if (phoneNumber.country === 'AC' || phoneNumber.country === 'TA') { 49 + countryCode = 'SH' 50 + } else { 51 + countryCode = phoneNumber.country 52 + } 53 + } 54 + return { 55 + valid: true, 56 + formatted: formatE164lWithoutCountryCode(phoneNumber), 57 + countryCode, 58 + } 59 + } catch (error) { 60 + if (error instanceof ParseError) { 61 + return {valid: false, reason: error.message} 62 + } else { 63 + return {valid: false} 64 + } 65 + } 66 + } 67 + 68 + /** 69 + * Format a phone number as the international format with the prefix 70 + * removed. 71 + */ 72 + function formatE164lWithoutCountryCode(phoneNumber: PhoneNumber) { 73 + const intl = phoneNumber.format('E.164') 74 + const prefix = '+' + phoneNumber.countryCallingCode 75 + return intl.replace(prefix, '').trim() 76 + } 77 + 78 + /** 79 + * Takes a country code and a prefix-less phone number and constructs a full phone number. 80 + * 81 + * Does not have nice error handling - if you're unsure if the number is valid, use 82 + * `processPhoneNumber` instead 83 + */ 84 + export function constructFullPhoneNumber( 85 + countryCode: CountryCode, 86 + phoneNumber: string, 87 + ) { 88 + const result = parsePhoneNumber(phoneNumber, {defaultCountry: countryCode}) 89 + if (!result.isValid()) 90 + throw new Error('Invalid phone number passed to constructFullPhoneNumber') 91 + return result.format('E.164') 92 + } 93 + 94 + /** 95 + * Takes a phone number and applies human-readable formatting. Do not sent to the API - they 96 + * expect E.164 format. 97 + */ 98 + export function prettyPhoneNumber(phoneNumber: string) { 99 + const result = parsePhoneNumber(phoneNumber) 100 + return result.formatNational() 101 + } 102 + 103 + /** 104 + * Attempts to parse a phone number from a string, and returns the country code 105 + * and the rest of the number if possible. If the number is invalid, returns undefined. 106 + */ 107 + export function getCountryCodeFromPastedNumber( 108 + text: string, 109 + ): {countryCode: CountryCode; rest: string} | undefined { 110 + try { 111 + const phoneNumber = parsePhoneNumber(text) 112 + if (!phoneNumber.isValid()) { 113 + return undefined 114 + } 115 + const countryCode = phoneNumber.country 116 + // we don't have AC and TA in our dropdown - see `#/lib/international-telephone-codes` 117 + if (countryCode && countryCode !== 'AC' && countryCode !== 'TA') { 118 + return { 119 + countryCode, 120 + rest: formatE164lWithoutCountryCode(phoneNumber), 121 + } 122 + } else { 123 + return undefined 124 + } 125 + } catch (error) { 126 + return undefined 127 + } 128 + } 129 + 130 + /** 131 + * Normalizes a phone number into E.164 format 132 + */ 133 + export function normalizePhoneNumber( 134 + rawNumber: string, 135 + countryCode: string | undefined, 136 + fallbackCountryCode: CountryCode, 137 + ): string | null { 138 + try { 139 + const result = parsePhoneNumber(rawNumber, { 140 + defaultCountry: 141 + countryCode && isSupportedCountry(countryCode) 142 + ? countryCode 143 + : fallbackCountryCode, 144 + }) 145 + 146 + if (!result.isValid()) return null 147 + 148 + const type = result.getType() 149 + if ( 150 + type !== 'MOBILE' && 151 + type !== 'FIXED_LINE_OR_MOBILE' && 152 + type !== 'PERSONAL_NUMBER' 153 + ) { 154 + return null 155 + } 156 + 157 + return result.format('E.164') 158 + } catch (error) { 159 + console.log('Failed to normalize phone number:', error) 160 + return null 161 + } 162 + }
+290
src/components/contacts/screens/GetContacts.tsx
··· 1 + import {Alert, View} from 'react-native' 2 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 3 + import * as Contacts from 'expo-contacts' 4 + import {AppBskyContactImportContacts} from '@atproto/api' 5 + import {msg, t, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + import {useMutation, useQueryClient} from '@tanstack/react-query' 8 + 9 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 10 + import {logger} from '#/logger' 11 + import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' 12 + import {useAgent} from '#/state/session' 13 + import {atoms as a, ios, tokens, useGutters} from '#/alf' 14 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 + import * as Layout from '#/components/Layout' 16 + import {Loader} from '#/components/Loader' 17 + import * as Toast from '#/components/Toast' 18 + import {Text} from '#/components/Typography' 19 + import { 20 + contactsWithPhoneNumbersOnly, 21 + filterMatchedNumbers, 22 + getMatchedContacts, 23 + normalizeContactBook, 24 + } from '../contacts' 25 + import {constructFullPhoneNumber} from '../phone-number' 26 + import {type Action, type State} from '../state' 27 + 28 + const MAX_UPLOAD_COUNT = 1000 29 + 30 + export function GetContacts({ 31 + state, 32 + dispatch, 33 + onCancel, 34 + context, 35 + }: { 36 + state: Extract<State, {step: '3: get contacts'}> 37 + dispatch: React.ActionDispatch<[Action]> 38 + onCancel: () => void 39 + context: 'Onboarding' | 'Standalone' 40 + }) { 41 + const {_} = useLingui() 42 + const agent = useAgent() 43 + const insets = useSafeAreaInsets() 44 + const gutters = useGutters([0, 'wide']) 45 + const queryClient = useQueryClient() 46 + 47 + const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({ 48 + mutationFn: async (contacts: Contacts.ExistingContact[]) => { 49 + const {phoneNumbers, indexToContactId} = normalizeContactBook( 50 + contacts, 51 + state.phoneCountryCode, 52 + constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber), 53 + ) 54 + 55 + if (phoneNumbers.length > 0) { 56 + const res = await agent.app.bsky.contact.importContacts({ 57 + token: state.token, 58 + contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT), 59 + }) 60 + 61 + return { 62 + matches: res.data.matchesAndContactIndexes, 63 + indexToContactId, 64 + } 65 + } else { 66 + return { 67 + matches: [], 68 + indexToContactId, 69 + } 70 + } 71 + }, 72 + onSuccess: (result, contacts) => { 73 + if (context === 'Onboarding') { 74 + logger.metric('onboarding:contacts:contactsShared', {}) 75 + } 76 + if (result.matches.length > 0) { 77 + logger.metric('contacts:import:success', { 78 + contactCount: contacts.length, 79 + matchCount: result.matches.length, 80 + entryPoint: context, 81 + }) 82 + } else { 83 + logger.metric('contacts:import:failure', { 84 + reason: 'noValidNumbers', 85 + entryPoint: context, 86 + }) 87 + } 88 + dispatch({ 89 + type: 'SYNC_CONTACTS_SUCCESS', 90 + payload: { 91 + matches: getMatchedContacts( 92 + contacts, 93 + result.matches, 94 + result.indexToContactId, 95 + ), 96 + contacts: filterMatchedNumbers( 97 + contacts, 98 + result.matches, 99 + result.indexToContactId, 100 + ), 101 + }, 102 + }) 103 + queryClient.invalidateQueries({ 104 + queryKey: findContactsStatusQueryKey, 105 + }) 106 + }, 107 + onError: err => { 108 + logger.metric('contacts:import:failure', { 109 + reason: isNetworkError(err) ? 'networkError' : 'unknown', 110 + entryPoint: context, 111 + }) 112 + if (isNetworkError(err)) { 113 + Toast.show( 114 + _( 115 + msg`There was a problem with your internet connection, please try again`, 116 + ), 117 + {type: 'error'}, 118 + ) 119 + } else if ( 120 + err instanceof AppBskyContactImportContacts.TooManyContactsError 121 + ) { 122 + Toast.show( 123 + _( 124 + msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`, 125 + ), 126 + {type: 'error'}, 127 + ) 128 + } else if ( 129 + err instanceof AppBskyContactImportContacts.InvalidTokenError 130 + ) { 131 + Toast.show( 132 + _( 133 + msg`Could not upload contacts. You need to re-verify your phone number to proceed`, 134 + ), 135 + {type: 'error'}, 136 + ) 137 + } else { 138 + logger.error('Error uploading contacts', {safeMessage: err}) 139 + Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), { 140 + type: 'error', 141 + }) 142 + } 143 + }, 144 + }) 145 + 146 + const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({ 147 + mutationFn: async () => { 148 + let permissions = await Contacts.getPermissionsAsync() 149 + 150 + if (!permissions.granted && permissions.canAskAgain) { 151 + permissions = await Contacts.requestPermissionsAsync() 152 + } 153 + 154 + logger.metric('contacts:permission:request', { 155 + status: permissions.granted ? 'granted' : 'denied', 156 + accessLevelIOS: ios(permissions.accessPrivileges), 157 + }) 158 + 159 + if (!permissions.granted) { 160 + throw new PermissionDeniedError() 161 + } 162 + 163 + const contacts = await Contacts.getContactsAsync({ 164 + fields: [ 165 + Contacts.Fields.FirstName, 166 + Contacts.Fields.LastName, 167 + Contacts.Fields.PhoneNumbers, 168 + Contacts.Fields.Image, 169 + ], 170 + }) 171 + 172 + return contactsWithPhoneNumbersOnly(contacts.data) 173 + }, 174 + onSuccess: contacts => { 175 + dispatch({ 176 + type: 'GET_CONTACTS_SUCCESS', 177 + payload: {contacts}, 178 + }) 179 + uploadContacts(contacts) 180 + }, 181 + onError: err => { 182 + if (err instanceof PermissionDeniedError) { 183 + showPermissionDeniedAlert() 184 + } else { 185 + logger.error('Error getting contacts', {safeMessage: err}) 186 + } 187 + }, 188 + }) 189 + 190 + const isPending = isUploadPending || isGetContactsPending 191 + 192 + const style = [a.text_md, a.leading_snug, a.mt_md] 193 + 194 + return ( 195 + <View style={[a.h_full]}> 196 + <Layout.Content 197 + contentContainerStyle={[gutters, a.flex_1, a.pt_xl]} 198 + bounces={false}> 199 + <Text style={[a.font_bold, a.text_3xl]}> 200 + <Trans>Share your contacts to find friends</Trans> 201 + </Text> 202 + <Text style={style}> 203 + <Trans> 204 + Bluesky helps friends find each other by creating an encoded digital 205 + fingerprint, called a "hash," and then looking for matching hashes. 206 + </Trans> 207 + </Text> 208 + <Text style={style}> 209 + &bull; <Trans>We never store plain phone numbers</Trans> 210 + </Text> 211 + <Text style={style}> 212 + &bull; <Trans>We delete hashes after matches are made</Trans> 213 + </Text> 214 + <Text style={style}> 215 + &bull; <Trans>We only suggest follows if both people consent</Trans> 216 + </Text> 217 + <Text style={style}> 218 + &bull; <Trans>You can always opt out and delete your data</Trans> 219 + </Text> 220 + <Text style={[style, a.mt_lg]}> 221 + <Trans> 222 + We apply the highest privacy standards, and never share or sell your 223 + contact information. 224 + </Trans> 225 + </Text> 226 + </Layout.Content> 227 + <View 228 + style={[ 229 + gutters, 230 + a.pt_xs, 231 + {paddingBottom: Math.max(insets.bottom, tokens.space.xl)}, 232 + a.gap_md, 233 + ]}> 234 + <Text style={[a.text_sm, a.pb_xs]}> 235 + <Trans> 236 + I consent to Bluesky using my contacts for mutual friend discovery 237 + and to retain hashed data for matching until I opt out. 238 + </Trans> 239 + </Text> 240 + <Button 241 + label={_(msg`Find my friends`)} 242 + size="large" 243 + color="primary" 244 + onPress={() => getContacts()} 245 + disabled={isPending}> 246 + {isUploadPending ? ( 247 + <> 248 + <ButtonText> 249 + <Trans>Finding friends...</Trans> 250 + </ButtonText> 251 + <ButtonIcon icon={Loader} /> 252 + </> 253 + ) : ( 254 + <ButtonText> 255 + <Trans>Find my friends</Trans> 256 + </ButtonText> 257 + )} 258 + </Button> 259 + <Button 260 + label={_(msg`Cancel`)} 261 + size="large" 262 + color="secondary" 263 + onPress={onCancel}> 264 + <ButtonText> 265 + <Trans>Cancel</Trans> 266 + </ButtonText> 267 + </Button> 268 + </View> 269 + </View> 270 + ) 271 + } 272 + 273 + class PermissionDeniedError extends Error { 274 + constructor() { 275 + super('Permission denied') 276 + } 277 + } 278 + 279 + function showPermissionDeniedAlert() { 280 + Alert.alert( 281 + t`You've denied access to your contacts`, 282 + t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`, 283 + [ 284 + { 285 + text: t`OK`, 286 + style: 'default', 287 + }, 288 + ], 289 + ) 290 + }
+326
src/components/contacts/screens/PhoneInput.tsx
··· 1 + import {useState} from 'react' 2 + import {Keyboard, View} from 'react-native' 3 + import {KeyboardAvoidingView} from 'react-native-keyboard-controller' 4 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 + import {AppBskyContactStartPhoneVerification} from '@atproto/api' 6 + import {msg, Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {useMutation} from '@tanstack/react-query' 9 + 10 + import { 11 + type CountryCode, 12 + getDefaultCountry, 13 + } from '#/lib/international-telephone-codes' 14 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 15 + import {logger} from '#/logger' 16 + import {useAgent} from '#/state/session' 17 + import {OnboardingPosition} from '#/screens/Onboarding/Layout' 18 + import { 19 + android, 20 + atoms as a, 21 + platform, 22 + tokens, 23 + useGutters, 24 + useTheme, 25 + } from '#/alf' 26 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27 + import * as TextField from '#/components/forms/TextField' 28 + import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect' 29 + import * as Layout from '#/components/Layout' 30 + import {InlineLinkText} from '#/components/Link' 31 + import {Loader} from '#/components/Loader' 32 + import {Text} from '#/components/Typography' 33 + import {useGeolocation} from '#/geolocation' 34 + import {isFindContactsFeatureEnabled} from '../country-allowlist' 35 + import { 36 + constructFullPhoneNumber, 37 + getCountryCodeFromPastedNumber, 38 + processPhoneNumber, 39 + } from '../phone-number' 40 + import {type Action, type State, useOnPressBackButton} from '../state' 41 + 42 + export function PhoneInput({ 43 + state, 44 + dispatch, 45 + context, 46 + onSkip, 47 + }: { 48 + state: Extract<State, {step: '1: phone input'}> 49 + dispatch: React.ActionDispatch<[Action]> 50 + context: 'Onboarding' | 'Standalone' 51 + onSkip: () => void 52 + }) { 53 + const {_} = useLingui() 54 + const t = useTheme() 55 + const agent = useAgent() 56 + const location = useGeolocation() 57 + const [countryCode, setCountryCode] = useState( 58 + () => state.phoneCountryCode ?? getDefaultCountry(location), 59 + ) 60 + const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '') 61 + const gutters = useGutters([0, 'wide']) 62 + const insets = useSafeAreaInsets() 63 + // for API/generic errors 64 + const [error, setError] = useState('') 65 + // for issues with parsing the number 66 + const [formatError, setFormatError] = useState('') 67 + 68 + const {mutate: submit, isPending} = useMutation({ 69 + mutationFn: async ({ 70 + phoneCountryCode, 71 + phoneNumber, 72 + }: { 73 + phoneCountryCode: CountryCode 74 + phoneNumber: string 75 + }) => { 76 + // sends a onetime code to the user's phone number 77 + await agent.app.bsky.contact.startPhoneVerification({ 78 + phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber), 79 + }) 80 + }, 81 + onSuccess: (_data, {phoneCountryCode, phoneNumber}) => { 82 + dispatch({ 83 + type: 'SUBMIT_PHONE_NUMBER', 84 + payload: {phoneCountryCode, phoneNumber}, 85 + }) 86 + 87 + logger.metric('contacts:phone:phoneEntered', {entryPoint: context}) 88 + }, 89 + onMutate: () => { 90 + Keyboard.dismiss() 91 + setError('') 92 + setFormatError('') 93 + }, 94 + onError: err => { 95 + if (isNetworkError(err)) { 96 + setError( 97 + _( 98 + msg`A network error occurred. Please check your internet connection`, 99 + ), 100 + ) 101 + } else if ( 102 + err instanceof 103 + AppBskyContactStartPhoneVerification.RateLimitExceededError 104 + ) { 105 + setError(_(msg`Rate limit exceeded. Please try again later.`)) 106 + } else if ( 107 + err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError 108 + ) { 109 + setError( 110 + _( 111 + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, 112 + ), 113 + ) 114 + } else { 115 + logger.error('Verify phone number failed', {safeMessage: err}) 116 + setError(_(msg`An error occurred. ${cleanError(err)}`)) 117 + } 118 + }, 119 + }) 120 + 121 + const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode) 122 + 123 + const onSubmitNumber = () => { 124 + if (!isFeatureEnabled) return 125 + if (!phoneNumber) return 126 + const result = processPhoneNumber(phoneNumber, countryCode) 127 + if (result.valid) { 128 + setPhoneNumber(result.formatted) 129 + setCountryCode(result.countryCode) 130 + 131 + if (!isFindContactsFeatureEnabled(result.countryCode)) return 132 + 133 + submit({ 134 + phoneCountryCode: result.countryCode, 135 + phoneNumber: result.formatted, 136 + }) 137 + } else { 138 + setFormatError(result.reason ?? _(msg`Invalid phone number`)) 139 + } 140 + } 141 + 142 + const paddingBottom = Math.max(insets.bottom, tokens.space.xl) 143 + 144 + const onPressBack = useOnPressBackButton() 145 + 146 + return ( 147 + <View style={[a.h_full]}> 148 + <Layout.Header.Outer noBottomBorder> 149 + <Layout.Header.BackButton onPress={onPressBack} /> 150 + <Layout.Header.Content /> 151 + {context === 'Onboarding' ? ( 152 + <Button 153 + size="small" 154 + color="secondary" 155 + variant="ghost" 156 + label={_(msg`Skip contact sharing and continue to the app`)} 157 + onPress={onSkip}> 158 + <ButtonText> 159 + <Trans>Skip</Trans> 160 + </ButtonText> 161 + </Button> 162 + ) : ( 163 + <Layout.Header.Slot /> 164 + )} 165 + </Layout.Header.Outer> 166 + <Layout.Content 167 + contentContainerStyle={[gutters, a.pt_sm, a.flex_1]} 168 + keyboardShouldPersistTaps="handled"> 169 + {context === 'Onboarding' && <OnboardingPosition />} 170 + <Text style={[a.font_bold, a.text_3xl]}> 171 + <Trans>Verify phone number</Trans> 172 + </Text> 173 + <Text 174 + style={[ 175 + a.text_md, 176 + t.atoms.text_contrast_medium, 177 + a.leading_snug, 178 + a.mt_sm, 179 + ]}> 180 + <Trans> 181 + We need to verify your number before we can look for your friends. A 182 + verification code will be sent to this number. 183 + </Trans> 184 + </Text> 185 + 186 + <View style={[a.mt_2xl]}> 187 + <TextField.LabelText> 188 + <Trans>Phone number</Trans> 189 + </TextField.LabelText> 190 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 191 + <View> 192 + <InternationalPhoneCodeSelect 193 + value={countryCode} 194 + onChange={value => setCountryCode(value)} 195 + /> 196 + </View> 197 + <View style={[a.flex_1]}> 198 + <TextField.Root isInvalid={!!formatError || !isFeatureEnabled}> 199 + <TextField.Input 200 + label={_(msg`Phone number`)} 201 + value={phoneNumber} 202 + onChangeText={text => { 203 + if (formatError) setFormatError('') 204 + if (Math.abs(text.length - phoneNumber.length) > 1) { 205 + // possibly pasted/autocompleted? auto-switch 206 + // country code if possible 207 + const result = getCountryCodeFromPastedNumber(text) 208 + if (result) { 209 + setCountryCode(result.countryCode) 210 + setPhoneNumber(result.rest) 211 + return 212 + } 213 + } 214 + setPhoneNumber(text) 215 + }} 216 + placeholder={null} 217 + keyboardType={platform({ 218 + ios: 'number-pad', 219 + android: 'phone-pad', 220 + })} 221 + autoComplete="tel" 222 + returnKeyType={android('next')} 223 + onSubmitEditing={onSubmitNumber} 224 + /> 225 + </TextField.Root> 226 + </View> 227 + </View> 228 + </View> 229 + 230 + {!isFeatureEnabled && ( 231 + <ErrorText> 232 + <Trans> 233 + Support for this feature in your country has not been enabled yet! 234 + Please check back later. 235 + </Trans> 236 + </ErrorText> 237 + )} 238 + {error && <ErrorText>{error}</ErrorText>} 239 + {formatError && <ErrorText>{formatError}</ErrorText>} 240 + 241 + <View style={[a.mt_auto, a.py_xl]}> 242 + <LegalDisclaimer /> 243 + </View> 244 + </Layout.Content> 245 + <KeyboardAvoidingView 246 + behavior="padding" 247 + keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}> 248 + <View style={[gutters, {paddingBottom}]}> 249 + <Button 250 + disabled={!phoneNumber || isPending} 251 + label={_(msg`Send code`)} 252 + size="large" 253 + color="primary" 254 + onPress={onSubmitNumber}> 255 + <ButtonText> 256 + <Trans>Send code</Trans> 257 + </ButtonText> 258 + {isPending && <ButtonIcon icon={Loader} />} 259 + </Button> 260 + </View> 261 + </KeyboardAvoidingView> 262 + </View> 263 + ) 264 + } 265 + 266 + function LegalDisclaimer() { 267 + const t = useTheme() 268 + const {_} = useLingui() 269 + 270 + const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug] 271 + 272 + return ( 273 + <View style={[a.gap_xs]}> 274 + <Text style={[style, a.font_medium]}> 275 + <Trans>How we use your number:</Trans> 276 + </Text> 277 + <Text style={style}> 278 + &bull;{' '} 279 + <Trans>Sent to our phone number verification provider Plivo</Trans> 280 + </Text> 281 + <Text style={style}> 282 + &bull; <Trans>Deleted by Plivo after verification</Trans> 283 + </Text> 284 + <Text style={style}> 285 + &bull;{' '} 286 + <Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans> 287 + </Text> 288 + <Text style={style}> 289 + &bull;{' '} 290 + <Trans>Stored as part of a secure code for matching with others</Trans> 291 + </Text> 292 + <Text style={[style, a.mt_xs]}> 293 + <Trans> 294 + By continuing, you consent to this use. You may change your mind any 295 + time by visiting settings.{' '} 296 + <InlineLinkText 297 + to="#" 298 + label={_( 299 + msg({ 300 + message: `Learn more about importing contacts`, 301 + context: `english-only-resource`, 302 + }), 303 + )} 304 + style={[a.text_xs, a.leading_snug]}> 305 + <Trans context="english-only-resource">Learn more</Trans> 306 + </InlineLinkText> 307 + </Trans> 308 + </Text> 309 + </View> 310 + ) 311 + } 312 + 313 + function ErrorText({children}: {children: React.ReactNode}) { 314 + const t = useTheme() 315 + return ( 316 + <Text 317 + style={[ 318 + a.text_md, 319 + {color: t.palette.negative_500}, 320 + a.leading_snug, 321 + a.mt_md, 322 + ]}> 323 + {children} 324 + </Text> 325 + ) 326 + }
+386
src/components/contacts/screens/VerifyNumber.tsx
··· 1 + import {useEffect, useMemo, useState} from 'react' 2 + import {Text as NestedText, View} from 'react-native' 3 + import { 4 + AppBskyContactStartPhoneVerification, 5 + AppBskyContactVerifyPhone, 6 + } from '@atproto/api' 7 + import {msg, Trans} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + import {useMutation} from '@tanstack/react-query' 10 + 11 + import {clamp} from '#/lib/numbers' 12 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 13 + import {logger} from '#/logger' 14 + import {useAgent} from '#/state/session' 15 + import {OnboardingPosition} from '#/screens/Onboarding/Layout' 16 + import {atoms as a, useGutters, useTheme} from '#/alf' 17 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 19 + import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' 20 + import {type Props as SVGIconProps} from '#/components/icons/common' 21 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 22 + import * as Layout from '#/components/Layout' 23 + import {Loader} from '#/components/Loader' 24 + import * as Toast from '#/components/Toast' 25 + import {Text} from '#/components/Typography' 26 + import {OTPInput} from '../components/OTPInput' 27 + import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number' 28 + import {type Action, type State, useOnPressBackButton} from '../state' 29 + 30 + export function VerifyNumber({ 31 + state, 32 + dispatch, 33 + context, 34 + onSkip, 35 + }: { 36 + state: Extract<State, {step: '2: verify number'}> 37 + dispatch: React.ActionDispatch<[Action]> 38 + context: 'Onboarding' | 'Standalone' 39 + onSkip: () => void 40 + }) { 41 + const t = useTheme() 42 + const {_} = useLingui() 43 + const agent = useAgent() 44 + const gutters = useGutters([0, 'wide']) 45 + 46 + const [otpCode, setOtpCode] = useState('') 47 + const [error, setError] = useState<{ 48 + retryable: boolean 49 + isResendError: boolean 50 + message: string 51 + } | null>(null) 52 + 53 + const [prevOtpCode, setPrevOtpCode] = useState(otpCode) 54 + if (otpCode !== prevOtpCode) { 55 + setPrevOtpCode(otpCode) 56 + setError(null) 57 + } 58 + 59 + const phone = useMemo( 60 + () => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber), 61 + [state.phoneCountryCode, state.phoneNumber], 62 + ) 63 + 64 + const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone]) 65 + 66 + const { 67 + mutate: verifyNumber, 68 + isPending, 69 + isSuccess, 70 + } = useMutation({ 71 + mutationFn: async (code: string) => { 72 + const res = await agent.app.bsky.contact.verifyPhone({code, phone}) 73 + return res.data.token 74 + }, 75 + onSuccess: async token => { 76 + // let the success state show for a moment 77 + setTimeout(() => { 78 + dispatch({ 79 + type: 'VERIFY_PHONE_NUMBER_SUCCESS', 80 + payload: { 81 + token, 82 + }, 83 + }) 84 + }, 1000) 85 + 86 + logger.metric('contacts:phone:phoneVerified', {entryPoint: context}) 87 + }, 88 + onMutate: () => setError(null), 89 + onError: err => { 90 + setOtpCode('') 91 + if (isNetworkError(err)) { 92 + setError({ 93 + retryable: true, 94 + isResendError: false, 95 + message: _( 96 + msg`A network error occurred. Please check your internet connection.`, 97 + ), 98 + }) 99 + } else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) { 100 + setError({ 101 + retryable: true, 102 + isResendError: true, 103 + message: _(msg`This code is invalid. Resend to get a new code.`), 104 + }) 105 + } else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) { 106 + setError({ 107 + retryable: false, 108 + isResendError: false, 109 + message: _( 110 + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, 111 + ), 112 + }) 113 + } else if ( 114 + err instanceof AppBskyContactVerifyPhone.RateLimitExceededError 115 + ) { 116 + setError({ 117 + retryable: true, 118 + isResendError: false, 119 + message: _( 120 + msg`Too many attempts. Please wait a few minutes and try again.`, 121 + ), 122 + }) 123 + } else { 124 + logger.error('Verify phone number failed', {safeMessage: err}) 125 + setError({ 126 + retryable: true, 127 + isResendError: false, 128 + message: _(msg`An error occurred. ${cleanError(err)}`), 129 + }) 130 + } 131 + }, 132 + }) 133 + 134 + const {mutate: resendCode, isPending: isResendingCode} = useMutation({ 135 + mutationFn: async () => { 136 + await agent.app.bsky.contact.startPhoneVerification({phone: phone}) 137 + }, 138 + onSuccess: () => { 139 + dispatch({type: 'RESEND_VERIFICATION_CODE'}) 140 + Toast.show(_(msg`A new code has been sent`)) 141 + }, 142 + onMutate: () => { 143 + setOtpCode('') 144 + setError(null) 145 + }, 146 + onError: err => { 147 + if (isNetworkError(err)) { 148 + setError({ 149 + retryable: true, 150 + isResendError: true, 151 + message: _( 152 + msg`A network error occurred. Please check your internet connection.`, 153 + ), 154 + }) 155 + } else if ( 156 + err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError 157 + ) { 158 + setError({ 159 + retryable: false, 160 + isResendError: true, 161 + message: _( 162 + msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`, 163 + ), 164 + }) 165 + } else if ( 166 + err instanceof 167 + AppBskyContactStartPhoneVerification.RateLimitExceededError 168 + ) { 169 + setError({ 170 + retryable: true, 171 + isResendError: true, 172 + message: _( 173 + msg`Too many codes sent. Please wait a few minutes and try again.`, 174 + ), 175 + }) 176 + } else { 177 + logger.error('Resend failed', {safeMessage: err}) 178 + setError({ 179 + retryable: true, 180 + isResendError: true, 181 + message: _(msg`An error occurred. ${cleanError(err)}`), 182 + }) 183 + } 184 + }, 185 + }) 186 + 187 + const onPressBack = useOnPressBackButton() 188 + 189 + return ( 190 + <View style={[a.h_full]}> 191 + <Layout.Header.Outer noBottomBorder> 192 + <Layout.Header.BackButton onPress={onPressBack} /> 193 + <Layout.Header.Content /> 194 + {context === 'Onboarding' ? ( 195 + <Button 196 + size="small" 197 + color="secondary" 198 + variant="ghost" 199 + label={_(msg`Skip contact sharing and continue to the app`)} 200 + onPress={onSkip}> 201 + <ButtonText> 202 + <Trans>Skip</Trans> 203 + </ButtonText> 204 + </Button> 205 + ) : ( 206 + <Layout.Header.Slot /> 207 + )} 208 + </Layout.Header.Outer> 209 + <Layout.Content 210 + contentContainerStyle={[gutters, a.pt_sm, a.flex_1]} 211 + keyboardShouldPersistTaps="always"> 212 + {context === 'Onboarding' && <OnboardingPosition />} 213 + <Text style={[a.font_bold, a.text_3xl]}> 214 + <Trans>Verify phone number</Trans> 215 + </Text> 216 + <Text 217 + style={[ 218 + a.text_md, 219 + t.atoms.text_contrast_medium, 220 + a.leading_snug, 221 + a.mt_sm, 222 + ]}> 223 + <Trans>Enter the 6-digit code sent to {prettyNumber}</Trans> 224 + </Text> 225 + <View style={[a.mt_2xl]}> 226 + <OTPInput 227 + label={_( 228 + msg`Enter 6-digit code that was sent to your phone number`, 229 + )} 230 + value={otpCode} 231 + onChange={setOtpCode} 232 + onComplete={code => verifyNumber(code)} 233 + /> 234 + </View> 235 + <View style={[a.mt_sm]}> 236 + <OTPStatus 237 + error={error} 238 + isPending={isPending} 239 + isResendingCode={isResendingCode} 240 + isSuccess={isSuccess} 241 + onResend={() => resendCode()} 242 + onRetry={() => verifyNumber(otpCode)} 243 + lastCodeSentAt={state.lastSentAt} 244 + /> 245 + </View> 246 + </Layout.Content> 247 + </View> 248 + ) 249 + } 250 + 251 + /** 252 + * Horrible component that takes all the state above and figures out what messages 253 + * and buttons to display. 254 + */ 255 + function OTPStatus({ 256 + error, 257 + isPending, 258 + isResendingCode, 259 + isSuccess, 260 + onResend, 261 + onRetry, 262 + lastCodeSentAt, 263 + }: { 264 + error: { 265 + retryable: boolean 266 + isResendError: boolean 267 + message: string 268 + } | null 269 + isPending: boolean 270 + isResendingCode: boolean 271 + isSuccess: boolean 272 + onResend: () => void 273 + onRetry: () => void 274 + lastCodeSentAt: Date | null 275 + }) { 276 + const {_} = useLingui() 277 + const t = useTheme() 278 + 279 + const [time, setTime] = useState(Date.now()) 280 + useEffect(() => { 281 + const interval = setInterval(() => { 282 + setTime(Date.now()) 283 + }, 1000) 284 + return () => clearInterval(interval) 285 + }, []) 286 + 287 + const timeUntilCanResend = Math.max( 288 + 0, 289 + 30000 - (time - (lastCodeSentAt?.getTime() ?? 0)), 290 + ) 291 + const isWaiting = timeUntilCanResend > 0 292 + 293 + let Icon: React.ComponentType<SVGIconProps> | null = null 294 + let text = '' 295 + let textColor = t.atoms.text_contrast_medium.color 296 + let showResendButton = false 297 + let showRetryButton = false 298 + 299 + if (isSuccess) { 300 + Icon = CircleCheckIcon 301 + text = _(msg`Phone number verified`) 302 + textColor = t.palette.positive_500 303 + } else if (isPending) { 304 + text = _(msg`Verifying...`) 305 + } else if (error) { 306 + Icon = WarningIcon 307 + text = error.message 308 + textColor = t.palette.negative_500 309 + if (error.retryable) { 310 + if (error.isResendError) { 311 + showResendButton = true 312 + } else { 313 + showRetryButton = true 314 + } 315 + } 316 + } else { 317 + showResendButton = true 318 + } 319 + 320 + return ( 321 + <View style={[a.w_full, a.align_center]}> 322 + {text && ( 323 + <View 324 + style={[ 325 + a.gap_xs, 326 + a.flex_row, 327 + a.align_center, 328 + (isSuccess || isPending) && a.mt_lg, 329 + ]}> 330 + {Icon && <Icon size="xs" style={{color: textColor}} />} 331 + <Text 332 + style={[ 333 + {color: textColor}, 334 + a.text_sm, 335 + a.leading_snug, 336 + a.text_center, 337 + ]}> 338 + {text} 339 + </Text> 340 + </View> 341 + )} 342 + 343 + {showRetryButton && ( 344 + <Button 345 + size="small" 346 + color="secondary_inverted" 347 + label={_(msg`Retry`)} 348 + onPress={onRetry} 349 + style={[a.mt_2xl]}> 350 + <ButtonIcon icon={RetryIcon} /> 351 + <ButtonText> 352 + <Trans>Retry</Trans> 353 + </ButtonText> 354 + </Button> 355 + )} 356 + 357 + {showResendButton && ( 358 + <Button 359 + size="large" 360 + color="secondary" 361 + variant="ghost" 362 + label={_(msg`Resend code`)} 363 + disabled={isResendingCode || isWaiting} 364 + onPress={onResend} 365 + style={[a.mt_2xl]}> 366 + {isResendingCode && <ButtonIcon icon={Loader} />} 367 + <ButtonText> 368 + {isWaiting ? ( 369 + <Trans> 370 + Resend code in{' '} 371 + <NestedText style={{fontVariant: ['tabular-nums']}}> 372 + 00: 373 + {String( 374 + clamp(Math.round(timeUntilCanResend / 1000), 0, 30), 375 + ).padStart(2, '0')} 376 + </NestedText> 377 + </Trans> 378 + ) : ( 379 + <Trans>Resend code</Trans> 380 + )} 381 + </ButtonText> 382 + </Button> 383 + )} 384 + </View> 385 + ) 386 + }
+683
src/components/contacts/screens/ViewMatches.tsx
··· 1 + import {useCallback, useMemo, useRef, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import * as SMS from 'expo-sms' 5 + import {type ModerationOpts} from '@atproto/api' 6 + import {msg, Plural, Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {useMutation, useQueryClient} from '@tanstack/react-query' 9 + 10 + import {wait} from '#/lib/async/wait' 11 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 12 + import {logger} from '#/logger' 13 + import { 14 + updateProfileShadow, 15 + useProfileShadow, 16 + } from '#/state/cache/profile-shadow' 17 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 18 + import { 19 + optimisticRemoveMatch, 20 + useMatchesPassthroughQuery, 21 + } from '#/state/queries/find-contacts' 22 + import {useAgent, useSession} from '#/state/session' 23 + import {List, type ListMethods} from '#/view/com/util/List' 24 + import {UserAvatar} from '#/view/com/util/UserAvatar' 25 + import {OnboardingPosition} from '#/screens/Onboarding/Layout' 26 + import {bulkWriteFollows} from '#/screens/Onboarding/util' 27 + import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import {SearchInput} from '#/components/forms/SearchInput' 30 + import {useInteractionState} from '#/components/hooks/useInteractionState' 31 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 32 + import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass' 33 + import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person' 34 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 35 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 36 + import * as Layout from '#/components/Layout' 37 + import {ListFooter} from '#/components/Lists' 38 + import {Loader} from '#/components/Loader' 39 + import * as ProfileCard from '#/components/ProfileCard' 40 + import * as Toast from '#/components/Toast' 41 + import {Text} from '#/components/Typography' 42 + import type * as bsky from '#/types/bsky' 43 + import {InviteInfo} from '../components/InviteInfo' 44 + import {type Action, type Contact, type Match, type State} from '../state' 45 + 46 + type Item = 47 + | { 48 + type: 'matches header' 49 + count: number 50 + } 51 + | { 52 + type: 'match' 53 + match: Match 54 + } 55 + | { 56 + type: 'contacts header' 57 + } 58 + | { 59 + type: 'contact' 60 + contact: Contact 61 + } 62 + | { 63 + type: 'no matches header' 64 + } 65 + | { 66 + type: 'search empty state' 67 + query: string 68 + } 69 + | { 70 + type: 'totally empty state' 71 + } 72 + 73 + export function ViewMatches({ 74 + state, 75 + dispatch, 76 + context, 77 + onNext, 78 + }: { 79 + state: Extract<State, {step: '4: view matches'}> 80 + dispatch: React.ActionDispatch<[Action]> 81 + context: 'Onboarding' | 'Standalone' 82 + onNext: () => void 83 + }) { 84 + const t = useTheme() 85 + const {_} = useLingui() 86 + const gutter = useGutters([0, 'wide']) 87 + const moderationOpts = useModerationOpts() 88 + const queryClient = useQueryClient() 89 + const agent = useAgent() 90 + const insets = useSafeAreaInsets() 91 + const listRef = useRef<ListMethods>(null) 92 + 93 + const [search, setSearch] = useState('') 94 + const { 95 + state: searchFocused, 96 + onIn: onFocus, 97 + onOut: onBlur, 98 + } = useInteractionState() 99 + 100 + // HACK: Although we already have the match data, we need to pass it through 101 + // a query to get it into the shadow state 102 + const allMatches = useMatchesPassthroughQuery(state.matches) 103 + const matches = allMatches.filter( 104 + match => !state.dismissedMatches.includes(match.profile.did), 105 + ) 106 + 107 + console.log(matches) 108 + 109 + const followableDids = matches.map(match => match.profile.did) 110 + const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) 111 + 112 + const cumulativeFollowCount = useRef(0) 113 + const onFollow = useCallback(() => { 114 + logger.metric('contacts:matches:follow', {entryPoint: context}) 115 + cumulativeFollowCount.current += 1 116 + }, [context]) 117 + 118 + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 119 + mutationFn: async () => { 120 + for (const did of followableDids) { 121 + updateProfileShadow(queryClient, did, { 122 + followingUri: 'pending', 123 + }) 124 + } 125 + 126 + const uris = await wait(500, bulkWriteFollows(agent, followableDids)) 127 + 128 + for (const did of followableDids) { 129 + const uri = uris.get(did) 130 + updateProfileShadow(queryClient, did, { 131 + followingUri: uri, 132 + }) 133 + } 134 + return followableDids 135 + }, 136 + onMutate: () => 137 + logger.metric('contacts:matches:followAll', { 138 + followCount: followableDids.length, 139 + entryPoint: context, 140 + }), 141 + onSuccess: () => { 142 + setDidFollowAll(true) 143 + Toast.show(_(msg`All friends followed!`), {type: 'success'}) 144 + cumulativeFollowCount.current += followableDids.length 145 + }, 146 + onError: _err => { 147 + Toast.show(_(msg`Failed to follow all your friends, please try again`), { 148 + type: 'error', 149 + }) 150 + for (const did of followableDids) { 151 + updateProfileShadow(queryClient, did, { 152 + followingUri: undefined, 153 + }) 154 + } 155 + }, 156 + }) 157 + 158 + const items = useMemo(() => { 159 + const all: Item[] = [] 160 + 161 + if (searchFocused || search.length > 0) { 162 + for (const match of matches) { 163 + if ( 164 + search.length === 0 || 165 + (match.profile.displayName ?? '') 166 + .toLocaleLowerCase() 167 + .includes(search.toLocaleLowerCase()) || 168 + match.profile.handle 169 + .toLocaleLowerCase() 170 + .includes(search.toLocaleLowerCase()) 171 + ) { 172 + all.push({type: 'match', match}) 173 + } 174 + } 175 + 176 + for (const contact of state.contacts) { 177 + if ( 178 + search.length === 0 || 179 + [contact.firstName, contact.lastName] 180 + .filter(Boolean) 181 + .join(' ') 182 + .toLocaleLowerCase() 183 + .includes(search.toLocaleLowerCase()) 184 + ) { 185 + all.push({type: 'contact', contact}) 186 + } 187 + } 188 + 189 + if (all.length === 0) { 190 + all.push({type: 'search empty state', query: search}) 191 + } 192 + } else { 193 + if (matches.length > 0) { 194 + all.push({type: 'matches header', count: matches.length}) 195 + for (const match of matches) { 196 + all.push({type: 'match', match}) 197 + } 198 + 199 + if (state.contacts.length > 0) { 200 + all.push({type: 'contacts header'}) 201 + } 202 + } else if (state.contacts.length > 0) { 203 + all.push({type: 'no matches header'}) 204 + } 205 + 206 + for (const contact of state.contacts) { 207 + all.push({type: 'contact', contact}) 208 + } 209 + 210 + if (all.length === 0) { 211 + all.push({type: 'totally empty state'}) 212 + } 213 + } 214 + 215 + return all 216 + }, [matches, state.contacts, search, searchFocused]) 217 + 218 + const {mutate: dismissMatch} = useMutation({ 219 + mutationFn: async (did: string) => { 220 + await agent.app.bsky.contact.dismissMatch({subject: did}) 221 + }, 222 + onMutate: did => { 223 + logger.metric('contacts:matches:dismiss', {entryPoint: context}) 224 + dispatch({type: 'DISMISS_MATCH', payload: {did}}) 225 + }, 226 + onSuccess: (_res, did) => { 227 + // for the other screen 228 + optimisticRemoveMatch(queryClient, did) 229 + }, 230 + onError: (err, did) => { 231 + dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}}) 232 + if (isNetworkError(err)) { 233 + Toast.show( 234 + _( 235 + msg`Failed to hide suggestion, please check your internet connection`, 236 + ), 237 + {type: 'error'}, 238 + ) 239 + } else { 240 + logger.error('Dismissing match failed', {safeMessage: err}) 241 + Toast.show( 242 + _(msg`An error occurred while hiding suggestion. ${cleanError(err)}`), 243 + {type: 'error'}, 244 + ) 245 + } 246 + }, 247 + }) 248 + 249 + const renderItem = ({item}: {item: Item}) => { 250 + switch (item.type) { 251 + case 'match': 252 + return ( 253 + <MatchItem 254 + profile={item.match.profile} 255 + contact={item.match.contact} 256 + moderationOpts={moderationOpts} 257 + onRemoveSuggestion={dismissMatch} 258 + onFollow={onFollow} 259 + /> 260 + ) 261 + case 'contact': 262 + return <ContactItem contact={item.contact} context={context} /> 263 + case 'matches header': 264 + return ( 265 + <Header 266 + titleText={ 267 + <Plural 268 + value={item.count} 269 + one="# friend found!" 270 + other="# friends found!" 271 + /> 272 + }> 273 + {item.count > 1 && ( 274 + <Button 275 + label={_(msg`Follow all`)} 276 + size="small" 277 + color="primary_subtle" 278 + onPress={() => followAll()} 279 + disabled={isFollowingAll || didFollowAll}> 280 + <ButtonIcon 281 + icon={ 282 + isFollowingAll 283 + ? Loader 284 + : !didFollowAll 285 + ? PlusIcon 286 + : CheckIcon 287 + } 288 + /> 289 + <ButtonText> 290 + <Trans>Follow all</Trans> 291 + </ButtonText> 292 + </Button> 293 + )} 294 + </Header> 295 + ) 296 + case 'contacts header': 297 + return ( 298 + <Header 299 + titleText={ 300 + <Trans> 301 + Invite friends{' '} 302 + <InviteInfo iconStyle={t.atoms.text} iconOffset={1} /> 303 + </Trans> 304 + } 305 + hasContentAbove 306 + /> 307 + ) 308 + case 'no matches header': 309 + return ( 310 + <Header 311 + titleText={_(msg`You got here first`)} 312 + largeTitle 313 + subtitleText={ 314 + <Trans> 315 + Bluesky is more fun with friends. Do you want to invite some of 316 + yours?{' '} 317 + <InviteInfo 318 + iconStyle={t.atoms.text_contrast_medium} 319 + iconOffset={2} 320 + /> 321 + </Trans> 322 + } 323 + /> 324 + ) 325 + case 'search empty state': 326 + return <SearchEmptyState query={item.query} /> 327 + case 'totally empty state': 328 + return <TotallyEmptyState /> 329 + } 330 + } 331 + 332 + const isSearchEmpty = items?.[0]?.type === 'search empty state' 333 + const isTotallyEmpty = items?.[0]?.type === 'totally empty state' 334 + 335 + const isEmpty = isSearchEmpty || isTotallyEmpty 336 + 337 + return ( 338 + <View style={[a.h_full]}> 339 + {context === 'Standalone' && ( 340 + <Layout.Header.Outer noBottomBorder> 341 + <Layout.Header.BackButton /> 342 + <Layout.Header.Content /> 343 + <Layout.Header.Slot /> 344 + </Layout.Header.Outer> 345 + )} 346 + {!isTotallyEmpty && ( 347 + <View 348 + style={[ 349 + gutter, 350 + a.mb_md, 351 + context === 'Onboarding' && [a.mt_sm, a.gap_sm], 352 + ]}> 353 + {context === 'Onboarding' && <OnboardingPosition />} 354 + <SearchInput 355 + placeholder={_(msg`Search contacts`)} 356 + value={search} 357 + onFocus={() => { 358 + onFocus() 359 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 360 + }} 361 + onBlur={() => { 362 + onBlur() 363 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 364 + }} 365 + onChangeText={text => { 366 + setSearch(text) 367 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 368 + }} 369 + onClearText={() => setSearch('')} 370 + /> 371 + </View> 372 + )} 373 + <List 374 + ref={listRef} 375 + data={items} 376 + renderItem={renderItem} 377 + ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null} 378 + keyExtractor={keyExtractor} 379 + keyboardDismissMode="interactive" 380 + automaticallyAdjustKeyboardInsets 381 + /> 382 + <View 383 + style={[ 384 + t.atoms.bg, 385 + t.atoms.border_contrast_low, 386 + a.border_t, 387 + a.align_center, 388 + a.align_stretch, 389 + gutter, 390 + a.pt_md, 391 + {paddingBottom: insets.bottom + tokens.space.md}, 392 + ]}> 393 + <Button 394 + label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)} 395 + onPress={() => { 396 + if (context === 'Onboarding') { 397 + logger.metric('onboarding:contacts:nextPressed', { 398 + matchCount: allMatches.length, 399 + followCount: cumulativeFollowCount.current, 400 + dismissedMatchCount: state.dismissedMatches.length, 401 + }) 402 + } 403 + onNext() 404 + }} 405 + size="large" 406 + color="primary"> 407 + <ButtonText> 408 + {context === 'Onboarding' ? ( 409 + <Trans>Next</Trans> 410 + ) : ( 411 + <Trans>Done</Trans> 412 + )} 413 + </ButtonText> 414 + </Button> 415 + </View> 416 + </View> 417 + ) 418 + } 419 + 420 + function keyExtractor(item: Item) { 421 + switch (item.type) { 422 + case 'contact': 423 + return item.contact.id 424 + case 'match': 425 + return item.match.profile.did 426 + default: 427 + return item.type 428 + } 429 + } 430 + 431 + function MatchItem({ 432 + profile, 433 + contact, 434 + moderationOpts, 435 + onRemoveSuggestion, 436 + onFollow, 437 + }: { 438 + profile: bsky.profile.AnyProfileView 439 + contact?: Contact 440 + moderationOpts?: ModerationOpts 441 + onRemoveSuggestion: (did: string) => void 442 + onFollow: () => void 443 + }) { 444 + const gutter = useGutters([0, 'wide']) 445 + const t = useTheme() 446 + const {_} = useLingui() 447 + const shadow = useProfileShadow(profile) 448 + 449 + const contactName = useMemo(() => { 450 + if (!contact) return null 451 + 452 + const name = contact.firstName ?? contact.lastName ?? contact.name 453 + if (name) return _(msg`Your contact ${name}`) 454 + const phone = 455 + contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] 456 + if (phone?.number) return phone.number 457 + return null 458 + }, [contact, _]) 459 + 460 + if (!moderationOpts) return null 461 + 462 + return ( 463 + <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}> 464 + <ProfileCard.Header> 465 + <ProfileCard.Avatar 466 + profile={profile} 467 + moderationOpts={moderationOpts} 468 + size={48} 469 + /> 470 + <View style={[a.flex_1]}> 471 + <ProfileCard.Name 472 + profile={profile} 473 + moderationOpts={moderationOpts} 474 + textStyle={[a.leading_tight]} 475 + /> 476 + <ProfileCard.Handle 477 + profile={profile} 478 + textStyle={[contactName && a.text_xs]} 479 + /> 480 + {contactName && ( 481 + <Text 482 + emoji 483 + style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]} 484 + numberOfLines={1}> 485 + {contactName} 486 + </Text> 487 + )} 488 + </View> 489 + <ProfileCard.FollowButton 490 + profile={profile} 491 + moderationOpts={moderationOpts} 492 + logContext="FindContacts" 493 + onFollow={onFollow} 494 + /> 495 + {!shadow.viewer?.following && ( 496 + <Button 497 + color="secondary" 498 + variant="ghost" 499 + label={_(msg`Remove suggestion`)} 500 + onPress={() => onRemoveSuggestion(profile.did)} 501 + hoverStyle={[a.bg_transparent, {opacity: 0.5}]} 502 + hitSlop={8}> 503 + <ButtonIcon icon={XIcon} /> 504 + </Button> 505 + )} 506 + </ProfileCard.Header> 507 + </View> 508 + ) 509 + } 510 + 511 + function ContactItem({ 512 + contact, 513 + context, 514 + }: { 515 + contact: Contact 516 + context: 'Onboarding' | 'Standalone' 517 + }) { 518 + const gutter = useGutters([0, 'wide']) 519 + const t = useTheme() 520 + const {_} = useLingui() 521 + const {currentAccount} = useSession() 522 + 523 + const name = contact.firstName ?? contact.lastName ?? contact.name 524 + const phone = 525 + contact.phoneNumbers?.find(phone => phone.isPrimary) ?? 526 + contact.phoneNumbers?.[0] 527 + const phoneNumber = phone?.number 528 + 529 + return ( 530 + <View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}> 531 + <ProfileCard.Header> 532 + {contact.image ? ( 533 + <UserAvatar size={40} avatar={contact.image.uri} type="user" /> 534 + ) : ( 535 + <View 536 + style={[ 537 + {width: 40, height: 40}, 538 + a.rounded_full, 539 + a.justify_center, 540 + a.align_center, 541 + t.atoms.bg_contrast_400, 542 + ]}> 543 + <Text 544 + style={[ 545 + a.text_lg, 546 + a.font_semi_bold, 547 + {color: t.palette.contrast_0}, 548 + ]}> 549 + {name?.[0]?.toLocaleUpperCase()} 550 + </Text> 551 + </View> 552 + )} 553 + <Text 554 + style={[ 555 + a.flex_1, 556 + a.text_md, 557 + a.font_medium, 558 + !name && [t.atoms.text_contrast_medium, a.italic], 559 + ]} 560 + numberOfLines={2}> 561 + {name ?? <Trans>No name</Trans>} 562 + </Text> 563 + {phoneNumber && currentAccount && ( 564 + <Button 565 + label={_(msg`Invite ${name} to join Bluesky`)} 566 + color="secondary" 567 + size="small" 568 + onPress={async () => { 569 + logger.metric('contacts:matches:invite', { 570 + entryPoint: context, 571 + }) 572 + try { 573 + await SMS.sendSMSAsync( 574 + [phoneNumber], 575 + _( 576 + msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`, 577 + ), 578 + ) 579 + } catch (err) { 580 + Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'}) 581 + logger.error('Could not launch SMS', {safeMessage: err}) 582 + } 583 + }}> 584 + <ButtonText> 585 + <Trans>Invite</Trans> 586 + </ButtonText> 587 + </Button> 588 + )} 589 + </ProfileCard.Header> 590 + </View> 591 + ) 592 + } 593 + 594 + function Header({ 595 + titleText, 596 + largeTitle, 597 + subtitleText, 598 + children, 599 + hasContentAbove, 600 + }: { 601 + titleText: React.ReactNode 602 + largeTitle?: boolean 603 + subtitleText?: React.ReactNode 604 + children?: React.ReactNode 605 + hasContentAbove?: boolean 606 + }) { 607 + const gutter = useGutters([0, 'wide']) 608 + const t = useTheme() 609 + 610 + return ( 611 + <View 612 + style={[ 613 + gutter, 614 + a.pb_md, 615 + a.gap_sm, 616 + hasContentAbove 617 + ? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low] 618 + : a.pt_md, 619 + ]}> 620 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 621 + <Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}> 622 + {titleText} 623 + </Text> 624 + {children} 625 + </View> 626 + {subtitleText && ( 627 + <Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}> 628 + {subtitleText} 629 + </Text> 630 + )} 631 + </View> 632 + ) 633 + } 634 + 635 + function SearchEmptyState({query}: {query: string}) { 636 + const t = useTheme() 637 + 638 + return ( 639 + <View 640 + style={[ 641 + a.flex_1, 642 + a.flex_col, 643 + a.align_center, 644 + a.justify_center, 645 + a.gap_lg, 646 + a.pt_5xl, 647 + a.px_5xl, 648 + ]}> 649 + <SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} /> 650 + <Text 651 + style={[ 652 + a.text_md, 653 + a.leading_snug, 654 + t.atoms.text_contrast_medium, 655 + a.text_center, 656 + ]}> 657 + <Trans>No contacts with the name “{query}” found</Trans> 658 + </Text> 659 + </View> 660 + ) 661 + } 662 + 663 + function TotallyEmptyState() { 664 + const t = useTheme() 665 + 666 + return ( 667 + <View 668 + style={[ 669 + a.flex_1, 670 + a.flex_col, 671 + a.align_center, 672 + a.justify_center, 673 + a.gap_lg, 674 + {paddingTop: 140}, 675 + a.px_5xl, 676 + ]}> 677 + <PersonXIcon width={64} style={[t.atoms.text_contrast_low]} /> 678 + <Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}> 679 + <Trans>No contacts found</Trans> 680 + </Text> 681 + </View> 682 + ) 683 + }
+198
src/components/contacts/state.ts
··· 1 + import {createContext, useContext, useReducer} from 'react' 2 + import {type GestureResponderEvent} from 'react-native' 3 + import {type ExistingContact} from 'expo-contacts' 4 + 5 + import {type CountryCode} from '#/lib/international-telephone-codes' 6 + import type * as bsky from '#/types/bsky' 7 + 8 + export type Contact = ExistingContact 9 + 10 + export type Match = { 11 + profile: bsky.profile.AnyProfileView 12 + contact?: Contact 13 + } 14 + 15 + export type State = 16 + | { 17 + step: '1: phone input' 18 + phoneCountryCode?: CountryCode 19 + phoneNumber?: string 20 + } 21 + | { 22 + step: '2: verify number' 23 + phoneCountryCode: CountryCode 24 + phoneNumber: string 25 + lastSentAt: Date | null 26 + } 27 + | { 28 + step: '3: get contacts' 29 + phoneCountryCode: CountryCode 30 + phoneNumber: string 31 + token: string 32 + contacts?: Contact[] 33 + } 34 + | { 35 + step: '4: view matches' 36 + contacts: Contact[] 37 + matches: Match[] 38 + // rather than mutating `matches`, we keep track of dismissed matches 39 + // so we can roll back optimistic updates 40 + dismissedMatches: string[] 41 + } 42 + 43 + export type Action = 44 + | { 45 + type: 'SUBMIT_PHONE_NUMBER' 46 + payload: { 47 + phoneCountryCode: CountryCode 48 + phoneNumber: string 49 + } 50 + } 51 + | { 52 + type: 'RESEND_VERIFICATION_CODE' 53 + } 54 + | { 55 + type: 'VERIFY_PHONE_NUMBER_SUCCESS' 56 + payload: { 57 + token: string 58 + } 59 + } 60 + | { 61 + type: 'GET_CONTACTS_SUCCESS' 62 + payload: { 63 + contacts: Contact[] 64 + } 65 + } 66 + | { 67 + type: 'SYNC_CONTACTS_SUCCESS' 68 + payload: { 69 + matches: Match[] 70 + // filter out matched contacts 71 + contacts: Contact[] 72 + } 73 + } 74 + | { 75 + type: 'BACK' 76 + } 77 + | { 78 + type: 'DISMISS_MATCH' 79 + payload: { 80 + did: string 81 + } 82 + } 83 + | { 84 + type: 'DISMISS_MATCH_FAILED' 85 + payload: { 86 + did: string 87 + } 88 + } 89 + 90 + function reducer(state: State, action: Action): State { 91 + switch (action.type) { 92 + case 'SUBMIT_PHONE_NUMBER': { 93 + assertCurrentStep(state, '1: phone input') 94 + return { 95 + step: '2: verify number', 96 + ...action.payload, 97 + lastSentAt: null, 98 + } 99 + } 100 + case 'RESEND_VERIFICATION_CODE': { 101 + assertCurrentStep(state, '2: verify number') 102 + return { 103 + ...state, 104 + lastSentAt: new Date(), 105 + } 106 + } 107 + case 'VERIFY_PHONE_NUMBER_SUCCESS': { 108 + assertCurrentStep(state, '2: verify number') 109 + return { 110 + step: '3: get contacts', 111 + token: action.payload.token, 112 + phoneCountryCode: state.phoneCountryCode, 113 + phoneNumber: state.phoneNumber, 114 + } 115 + } 116 + case 'BACK': { 117 + assertCurrentStep(state, '2: verify number') 118 + return { 119 + step: '1: phone input', 120 + phoneNumber: state.phoneNumber, 121 + phoneCountryCode: state.phoneCountryCode, 122 + } 123 + } 124 + case 'GET_CONTACTS_SUCCESS': { 125 + assertCurrentStep(state, '3: get contacts') 126 + return { 127 + ...state, 128 + contacts: action.payload.contacts, 129 + } 130 + } 131 + case 'SYNC_CONTACTS_SUCCESS': { 132 + assertCurrentStep(state, '3: get contacts') 133 + return { 134 + step: '4: view matches', 135 + contacts: action.payload.contacts, 136 + matches: action.payload.matches, 137 + dismissedMatches: [], 138 + } 139 + } 140 + case 'DISMISS_MATCH': { 141 + assertCurrentStep(state, '4: view matches') 142 + return { 143 + ...state, 144 + dismissedMatches: [ 145 + ...new Set(state.dismissedMatches), 146 + action.payload.did, 147 + ], 148 + } 149 + } 150 + case 'DISMISS_MATCH_FAILED': { 151 + assertCurrentStep(state, '4: view matches') 152 + return { 153 + ...state, 154 + dismissedMatches: state.dismissedMatches.filter( 155 + did => did !== action.payload.did, 156 + ), 157 + } 158 + } 159 + } 160 + } 161 + 162 + class InvalidStateTransitionError extends Error { 163 + constructor(message: string) { 164 + super(message) 165 + this.name = 'InvalidStateTransitionError' 166 + } 167 + } 168 + 169 + function assertCurrentStep<S extends State['step']>( 170 + state: State, 171 + step: S, 172 + ): asserts state is Extract<State, {step: S}> { 173 + if (state.step !== step) { 174 + throw new InvalidStateTransitionError( 175 + `Invalid state transition: expecting ${step}, got ${state.step}`, 176 + ) 177 + } 178 + } 179 + 180 + export function useFindContactsFlowState( 181 + initialState: State = {step: '1: phone input'}, 182 + ) { 183 + return useReducer(reducer, initialState) 184 + } 185 + 186 + export const FindContactsGoBackContext = createContext< 187 + (() => void) | undefined 188 + >(undefined) 189 + export function useOnPressBackButton() { 190 + const goBack = useContext(FindContactsGoBackContext) 191 + if (!goBack) { 192 + return undefined 193 + } 194 + return (evt: GestureResponderEvent) => { 195 + evt.preventDefault() 196 + goBack() 197 + } 198 + }
+1 -1
src/components/dialogs/GifSelect.tsx
··· 29 29 import * as TextField from '#/components/forms/TextField' 30 30 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 31 31 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 32 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 32 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 33 33 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 34 34 35 35 export function GifSelectDialog({
+1 -1
src/components/dialogs/SearchablePeopleList.tsx
··· 25 25 import * as Dialog from '#/components/Dialog' 26 26 import {canBeMessaged} from '#/components/dms/util' 27 27 import {useInteractionState} from '#/components/hooks/useInteractionState' 28 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 28 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 29 29 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30 30 import * as ProfileCard from '#/components/ProfileCard' 31 31 import {Text} from '#/components/Typography'
+116
src/components/dialogs/nuxs/FindContactsAnnouncement.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {logger} from '#/logger' 9 + import {isWeb} from '#/platform/detection' 10 + import {atoms as a, useTheme, web} from '#/alf' 11 + import {Button, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 14 + import {Text} from '#/components/Typography' 15 + import {navigate} from '#/Navigation' 16 + 17 + export function FindContactsAnnouncement() { 18 + const t = useTheme() 19 + const {_} = useLingui() 20 + const nuxDialogs = useNuxDialogContext() 21 + const control = Dialog.useDialogControl() 22 + 23 + Dialog.useAutoOpen(control) 24 + 25 + const onClose = useCallback(() => { 26 + nuxDialogs.dismissActiveNux() 27 + }, [nuxDialogs]) 28 + 29 + return ( 30 + <Dialog.Outer 31 + control={control} 32 + onClose={onClose} 33 + nativeOptions={{preventExpansion: true}}> 34 + <Dialog.Handle /> 35 + 36 + <Dialog.ScrollableInner 37 + label={_(msg`Introducing finding friends via contacts`)} 38 + style={[web({maxWidth: 440})]} 39 + contentContainerStyle={[ 40 + { 41 + paddingTop: 0, 42 + paddingLeft: 0, 43 + paddingRight: 0, 44 + }, 45 + ]}> 46 + <View style={[a.align_center, a.pt_3xl]}> 47 + <LinearGradient 48 + colors={[t.palette.primary_200, t.atoms.bg.backgroundColor]} 49 + locations={[0, 1]} 50 + start={{x: 0, y: 0}} 51 + end={{x: 0, y: 1}} 52 + style={[a.absolute, a.inset_0]} 53 + /> 54 + <View style={[a.w_full, a.pt_sm, a.px_5xl, a.pb_4xl]}> 55 + <Image 56 + accessibilityIgnoresInvertColors 57 + source={require('../../../../assets/images/find_friends_illustration.webp')} 58 + style={[a.w_full, {aspectRatio: 1278 / 661}]} 59 + alt={_( 60 + msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`, 61 + )} 62 + /> 63 + </View> 64 + </View> 65 + <View style={[a.align_center, a.px_xl, a.gap_5xl]}> 66 + <View style={[a.gap_sm, a.align_center]}> 67 + <Text 68 + style={[ 69 + a.text_4xl, 70 + a.leading_tight, 71 + a.font_bold, 72 + a.text_center, 73 + { 74 + fontSize: isWeb ? 28 : 32, 75 + maxWidth: 300, 76 + }, 77 + ]}> 78 + <Trans>Find your friends</Trans> 79 + </Text> 80 + <Text 81 + style={[ 82 + a.text_md, 83 + t.atoms.text_contrast_medium, 84 + a.leading_snug, 85 + a.text_center, 86 + {maxWidth: 340}, 87 + ]}> 88 + <Trans> 89 + Bluesky is more fun with friends! Import your contacts to see 90 + who’s already here. 91 + </Trans> 92 + </Text> 93 + </View> 94 + 95 + <Button 96 + label={_(msg`Import Contacts`)} 97 + size="large" 98 + color="primary" 99 + onPress={() => { 100 + logger.metric('contacts:nux:ctaPressed', {}) 101 + control.close(() => { 102 + navigate('FindContactsFlow') 103 + }) 104 + }} 105 + style={[a.w_full]}> 106 + <ButtonText> 107 + <Trans>Import Contacts</Trans> 108 + </ButtonText> 109 + </Button> 110 + </View> 111 + 112 + <Dialog.Close /> 113 + </Dialog.ScrollableInner> 114 + </Dialog.Outer> 115 + ) 116 + }
+26 -16
src/components/dialogs/nuxs/index.tsx
··· 1 - import React from 'react' 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useEffect, 6 + useMemo, 7 + useState, 8 + } from 'react' 2 9 import {type AppBskyActorDefs} from '@atproto/api' 3 10 4 11 import {useGate} from '#/lib/statsig/statsig' 5 12 import {logger} from '#/logger' 13 + import {isNative} from '#/platform/detection' 6 14 import {STALE} from '#/state/queries' 7 15 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' 8 16 import { ··· 12 20 import {useProfileQuery} from '#/state/queries/profile' 13 21 import {type SessionAccount, useSession} from '#/state/session' 14 22 import {useOnboardingState} from '#/state/shell' 15 - import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement' 23 + import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 16 24 /* 17 25 * NUXs 18 26 */ 19 - import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 27 + import {FindContactsAnnouncement} from './FindContactsAnnouncement' 20 28 import {isExistingUserAsOf} from './utils' 21 29 22 30 type Context = { ··· 34 42 }) => boolean 35 43 }[] = [ 36 44 { 37 - id: Nux.BookmarksAnnouncement, 45 + id: Nux.FindContactsAnnouncement, 38 46 enabled: ({currentProfile}) => { 39 - return isExistingUserAsOf( 40 - '2025-09-08T00:00:00.000Z', 41 - currentProfile.createdAt, 47 + return ( 48 + isNative && 49 + isExistingUserAsOf('2025-12-16T00:00:00.000Z', currentProfile.createdAt) 42 50 ) 43 51 }, 44 52 }, 45 53 ] 46 54 47 - const Context = React.createContext<Context>({ 55 + const Context = createContext<Context>({ 48 56 activeNux: undefined, 49 57 dismissActiveNux: () => {}, 50 58 }) 51 59 Context.displayName = 'NuxDialogContext' 52 60 53 61 export function useNuxDialogContext() { 54 - return React.useContext(Context) 62 + return useContext(Context) 55 63 } 56 64 57 65 export function NuxDialogs() { ··· 92 100 }) { 93 101 const gate = useGate() 94 102 const {nuxs} = useNuxs() 95 - const [snoozed, setSnoozed] = React.useState(() => { 103 + const [snoozed, setSnoozed] = useState(() => { 96 104 return isSnoozed() 97 105 }) 98 - const [activeNux, setActiveNux] = React.useState<Nux | undefined>() 106 + const [activeNux, setActiveNux] = useState<Nux | undefined>() 99 107 const {mutateAsync: saveNux} = useSaveNux() 100 108 const {mutate: resetNuxs} = useResetNuxs() 101 109 102 - const snoozeNuxDialog = React.useCallback(() => { 110 + const snoozeNuxDialog = useCallback(() => { 103 111 snooze() 104 112 setSnoozed(true) 105 113 }, [setSnoozed]) 106 114 107 - const dismissActiveNux = React.useCallback(() => { 115 + const dismissActiveNux = useCallback(() => { 108 116 if (!activeNux) return 109 117 setActiveNux(undefined) 110 118 }, [activeNux, setActiveNux]) ··· 118 126 } 119 127 } 120 128 121 - React.useEffect(() => { 129 + useEffect(() => { 122 130 if (snoozed) return // comment this out to test 123 131 if (!nuxs) return 124 132 ··· 170 178 preferences, 171 179 ]) 172 180 173 - const ctx = React.useMemo(() => { 181 + const ctx = useMemo(() => { 174 182 return { 175 183 activeNux, 176 184 dismissActiveNux, ··· 180 188 return ( 181 189 <Context.Provider value={ctx}> 182 190 {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} 183 - {activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />} 191 + {activeNux === Nux.FindContactsAnnouncement && ( 192 + <FindContactsAnnouncement /> 193 + )} 184 194 </Context.Provider> 185 195 ) 186 196 }
+1 -1
src/components/forms/SearchInput.tsx
··· 8 8 import {atoms as a, useTheme} from '#/alf' 9 9 import {Button, ButtonIcon} from '#/components/Button' 10 10 import * as TextField from '#/components/forms/TextField' 11 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2' 11 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass' 12 12 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 13 13 14 14 type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
+14 -8
src/components/forms/TextField.tsx
··· 141 141 }, [t]) 142 142 } 143 143 144 - export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { 144 + export type InputProps = Omit< 145 + TextInputProps, 146 + 'value' | 'onChangeText' | 'placeholder' 147 + > & { 145 148 label: string 146 149 /** 147 150 * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible. 148 151 * 149 152 * See https://github.com/facebook/react-native-website/pull/4247 153 + * 154 + * Note: This guidance no longer applies once we migrate to the New Architecture! 150 155 */ 151 156 value?: string 152 157 onChangeText?: (value: string) => void 153 158 isInvalid?: boolean 154 159 inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput> 160 + /** 161 + * Note: this currently falls back to the label if not specified. However, 162 + * most new designs have no placeholder. We should eventually remove this fallback 163 + * behaviour, but for now just pass `null` if you want no placeholder -sfn 164 + */ 165 + placeholder?: string | null | undefined 155 166 } 156 167 157 168 export function createInput(Component: typeof TextInput) { ··· 255 266 ctx.onBlur() 256 267 onBlur?.(e) 257 268 }} 258 - placeholder={placeholder || label} 269 + placeholder={placeholder === null ? undefined : placeholder || label} 259 270 placeholderTextColor={t.palette.contrast_500} 260 271 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 261 272 style={flattened} ··· 292 303 return ( 293 304 <Text 294 305 nativeID={nativeID} 295 - style={[ 296 - a.text_sm, 297 - a.font_semi_bold, 298 - t.atoms.text_contrast_medium, 299 - a.mb_sm, 300 - ]}> 306 + style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}> 301 307 {children} 302 308 </Text> 303 309 )
+12
src/components/icons/ArrowRotate.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded = 4 + createSinglePathSVG({ 5 + 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', 6 + }) 7 + 8 + export const ArrowRotateClockwise_Stroke2_Corner0_Rounded = createSinglePathSVG( 9 + { 10 + 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', 11 + }, 12 + )
-6
src/components/icons/ArrowRotateCounterClockwise.tsx
··· 1 - import {createSinglePathSVG} from './TEMPLATE' 2 - 3 - export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded = 4 - createSinglePathSVG({ 5 - 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', 6 - })
+9
src/components/icons/Contacts.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Contacts_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + 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', 5 + }) 6 + 7 + export const Contacts_Filled_Corner2_Rounded = createSinglePathSVG({ 8 + 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', 9 + })
+14
src/components/icons/MagnifyingGlass.tsx
··· 1 1 import {createSinglePathSVG} from './TEMPLATE' 2 2 3 + export const MagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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 + }) 6 + 3 7 export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded = 4 8 createSinglePathSVG({ 5 9 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', 6 10 }) 11 + 12 + export const MagnifyingGlassX_Stroke2_Corner0_Rounded = createSinglePathSVG({ 13 + 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', 14 + }) 15 + 16 + export const MagnifyingGlassX_Stroke2_Corner0_Rounded_Large = 17 + createSinglePathSVG({ 18 + viewBox: '0 0 64 64', 19 + 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', 20 + })
-5
src/components/icons/MagnifyingGlass2.tsx
··· 1 - import {createSinglePathSVG} from './TEMPLATE' 2 - 3 - export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 - 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 - })
+5
src/components/icons/Person.tsx
··· 16 16 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', 17 17 }) 18 18 19 + export const PersonX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({ 20 + viewBox: '0 0 64 64', 21 + 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', 22 + }) 23 + 19 24 export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ 20 25 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', 21 26 })
+1 -1
src/components/intents/VerifyEmailIntentDialog.tsx
··· 11 11 import {type DialogControlProps} from '#/components/Dialog' 12 12 import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail' 13 13 import {Divider} from '#/components/Divider' 14 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise' 14 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotate' 15 15 import {useIntentDialogs} from '#/components/intents/IntentDialogs' 16 16 import {Loader} from '#/components/Loader' 17 17 import {Text} from '#/components/Typography'
+1 -1
src/components/moderation/ReportDialog/index.tsx
··· 18 18 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 19 import * as Dialog from '#/components/Dialog' 20 20 import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 21 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' 21 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' 22 22 import { 23 23 Check_Stroke2_Corner0_Rounded as CheckThin, 24 24 CheckThick_Stroke2_Corner0_Rounded as Check,
+70 -50
src/lib/international-telephone-codes.ts
··· 1 + import {type CountryCode as LibPhoneNumberJsCountryCode} from 'libphonenumber-js' 2 + 3 + // Exclude Ascension Island and Tristan da Cunha - merged into `SH` in 2009 4 + export type CountryCode = Exclude<LibPhoneNumberJsCountryCode, 'AC' | 'TA'> 5 + 6 + /** 7 + * Note: data is from Wikipedia, but some have been removed to match `libphonenumber-js` 8 + * Mostly tiny British overseas territories + Antarctica, all of which 9 + * share codes with a larger country. If you've one of the 10 people from these 10 + * places, you probably know what to do. 11 + */ 1 12 export const INTERNATIONAL_TELEPHONE_CODES = { 2 13 AD: { 3 14 code: '+376', ··· 34 45 unicodeFlag: '🇦🇴', 35 46 svgFlag: require('../../assets/icons/flags/AO.svg'), 36 47 }, 37 - AQ: { 38 - code: '+672', 39 - unicodeFlag: '🇦🇶', 40 - svgFlag: require('../../assets/icons/flags/AQ.svg'), 41 - }, 48 + // sorry penguins :( 49 + // same as Norfolk Island 50 + // AQ: { 51 + // code: '+672', 52 + // unicodeFlag: '🇦🇶', 53 + // svgFlag: require('../../assets/icons/flags/AQ.svg'), 54 + // }, 42 55 AR: { 43 56 code: '+54', 44 57 unicodeFlag: '🇦🇷', ··· 154 167 unicodeFlag: '🇧🇹', 155 168 svgFlag: require('../../assets/icons/flags/BT.svg'), 156 169 }, 157 - BV: { 158 - code: '+47', 159 - unicodeFlag: '🇧🇻', 160 - svgFlag: require('../../assets/icons/flags/BV.svg'), 161 - }, 170 + // same as Norway 171 + // BV: { 172 + // code: '+47', 173 + // unicodeFlag: '🇧🇻', 174 + // svgFlag: require('../../assets/icons/flags/BV.svg'), 175 + // }, 162 176 BW: { 163 177 code: '+267', 164 178 unicodeFlag: '🇧🇼', ··· 379 393 unicodeFlag: '🇬🇷', 380 394 svgFlag: require('../../assets/icons/flags/GR.svg'), 381 395 }, 382 - GS: { 383 - code: '+500', 384 - unicodeFlag: '🇬🇸', 385 - svgFlag: require('../../assets/icons/flags/GS.svg'), 386 - }, 396 + // same as Falkland Islands 397 + // GS: { 398 + // code: '+500', 399 + // unicodeFlag: '🇬🇸', 400 + // svgFlag: require('../../assets/icons/flags/GS.svg'), 401 + // }, 387 402 GT: { 388 403 code: '+502', 389 404 unicodeFlag: '🇬🇹', ··· 779 794 unicodeFlag: '🇵🇲', 780 795 svgFlag: require('../../assets/icons/flags/PM.svg'), 781 796 }, 782 - PN: { 783 - code: '+64', 784 - unicodeFlag: '🇵🇳', 785 - svgFlag: require('../../assets/icons/flags/PN.svg'), 786 - }, 797 + // same as New Zealand 798 + // PN: { 799 + // code: '+64', 800 + // unicodeFlag: '🇵🇳', 801 + // svgFlag: require('../../assets/icons/flags/PN.svg'), 802 + // }, 787 803 PR: { 788 804 code: '+1', 789 805 unicodeFlag: '🇵🇷', ··· 1199 1215 unicodeFlag: '🇫🇴', 1200 1216 svgFlag: require('../../assets/icons/flags/FO.svg'), 1201 1217 }, 1202 - HM: { 1203 - code: '+672', 1204 - unicodeFlag: '🇭🇲', 1205 - svgFlag: require('../../assets/icons/flags/HM.svg'), 1206 - }, 1218 + // same as Norfolk Island 1219 + // HM: { 1220 + // code: '+672', 1221 + // unicodeFlag: '🇭🇲', 1222 + // svgFlag: require('../../assets/icons/flags/HM.svg'), 1223 + // }, 1207 1224 KM: { 1208 1225 code: '+269', 1209 1226 unicodeFlag: '🇰🇲', ··· 1229 1246 unicodeFlag: '🇹🇨', 1230 1247 svgFlag: require('../../assets/icons/flags/TC.svg'), 1231 1248 }, 1232 - TF: { 1233 - code: '+672', 1234 - unicodeFlag: '🇹🇫', 1235 - svgFlag: require('../../assets/icons/flags/TF.svg'), 1236 - }, 1237 - UM: { 1238 - code: '+1', 1239 - unicodeFlag: '🇺🇲', 1240 - svgFlag: require('../../assets/icons/flags/UM.svg'), 1241 - }, 1249 + // same as Norfolk Island 1250 + // TF: { 1251 + // code: '+672', 1252 + // unicodeFlag: '🇹🇫', 1253 + // svgFlag: require('../../assets/icons/flags/TF.svg'), 1254 + // }, 1255 + // same as US mainland 1256 + // UM: { 1257 + // code: '+1', 1258 + // unicodeFlag: '🇺🇲', 1259 + // svgFlag: require('../../assets/icons/flags/UM.svg'), 1260 + // }, 1242 1261 VA: { 1243 1262 code: '+39', 1244 1263 unicodeFlag: '🇻🇦', ··· 1249 1268 unicodeFlag: '🇽🇰', 1250 1269 svgFlag: require('../../assets/icons/flags/XK.svg'), 1251 1270 }, 1252 - } 1271 + } satisfies Record< 1272 + CountryCode, 1273 + { 1274 + code: string 1275 + unicodeFlag: string 1276 + svgFlag: any 1277 + } 1278 + > 1253 1279 1254 - const DEFAULT_PHONE_COUNTRY = 'US' 1280 + const DEFAULT_PHONE_COUNTRY = 'US' as const 1255 1281 1256 - export function getDefaultCountry(location?: {countryCode?: string}) { 1282 + export function getDefaultCountry(location?: { 1283 + countryCode?: string 1284 + }): CountryCode { 1285 + const locationCountryCode = location?.countryCode?.toUpperCase() 1257 1286 if ( 1258 - location?.countryCode && 1259 - location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES 1287 + locationCountryCode && 1288 + locationCountryCode in INTERNATIONAL_TELEPHONE_CODES 1260 1289 ) { 1261 - return location.countryCode.toUpperCase() 1290 + return locationCountryCode as CountryCode 1262 1291 } 1263 1292 return DEFAULT_PHONE_COUNTRY 1264 1293 } 1265 - 1266 - export function getPhoneCodeFromCountryCode(countryCode: string) { 1267 - const country = 1268 - INTERNATIONAL_TELEPHONE_CODES[ 1269 - countryCode.toUpperCase() as keyof typeof INTERNATIONAL_TELEPHONE_CODES 1270 - ] 1271 - if (!country) throw new Error(`Country ${countryCode} not found`) 1272 - return country.code 1273 - }
+2
src/lib/routes/types.ts
··· 67 67 InterestsSettings: undefined 68 68 AboutSettings: undefined 69 69 AppIconSettings: undefined 70 + FindContactsSettings: undefined 70 71 Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} 71 72 Hashtag: {tag: string; author?: string} 72 73 Topic: {topic: string} ··· 87 88 StarterPackEdit: {rkey?: string} 88 89 VideoFeed: VideoFeedSourceContext 89 90 Bookmarks: undefined 91 + FindContactsFlow: undefined 90 92 } 91 93 92 94 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/lib/statsig/gates.ts
··· 3 3 | 'alt_share_icon' 4 4 | 'debug_show_feedcontext' 5 5 | 'debug_subscriptions' 6 + | 'disable_onboarding_find_contacts' 6 7 | 'explore_show_suggested_feeds' 7 8 | 'feed_reply_button_open_thread' 8 9 | 'old_postonboarding'
+89
src/logger/metrics.ts
··· 309 309 | 'ImmersiveVideo' 310 310 | 'ExploreSuggestedAccounts' 311 311 | 'OnboardingSuggestedAccounts' 312 + | 'FindContacts' 312 313 } 313 314 'profile:followers:view': { 314 315 contextProfileDid: string ··· 396 397 | 'ImmersiveVideo' 397 398 | 'ExploreSuggestedAccounts' 398 399 | 'OnboardingSuggestedAccounts' 400 + | 'FindContacts' 399 401 } 400 402 'chat:create': { 401 403 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' ··· 625 627 'blockedGeoOverlay:shown': {} 626 628 627 629 'geo:debug': {} 630 + 631 + /* 632 + * Find Contacts stuff 633 + */ 634 + 635 + // user presses the button on the new feature NUX 636 + 'contacts:nux:ctaPressed': {} 637 + // user presses the banner NUX 638 + 'contacts:nux:bannerPressed': {} 639 + // user dismisses the banner 640 + 'contacts:nux:bannerDismissed': {} 641 + 642 + // user lands on the contacts step 643 + 'onboarding:contacts:presented': {} 644 + // user pressed "Import Contacts" button to begin flow 645 + 'onboarding:contacts:begin': {} 646 + // skips the step entirely 647 + 'onboarding:contacts:skipPressed': {} 648 + // user shared their contacts 649 + 'onboarding:contacts:contactsShared': {} 650 + // user leaves the matches page 651 + 'onboarding:contacts:nextPressed': { 652 + matchCount: number 653 + followCount: number 654 + dismissedMatchCount: number 655 + } 656 + 657 + // user entered a number 658 + 'contacts:phone:phoneEntered': { 659 + entryPoint: 'Onboarding' | 'Standalone' 660 + } 661 + // user entered the correct one-time-code 662 + 'contacts:phone:phoneVerified': { 663 + entryPoint: 'Onboarding' | 'Standalone' 664 + } 665 + // user responded to the contacts permission prompt 666 + 'contacts:permission:request': { 667 + status: 'granted' | 'denied' 668 + accessLevelIOS?: 'all' | 'limited' | 'none' 669 + } 670 + // contacts were successfully imported and matched 671 + 'contacts:import:success': { 672 + contactCount: number 673 + matchCount: number 674 + entryPoint: 'Onboarding' | 'Standalone' 675 + } 676 + // contacts import failed 677 + 'contacts:import:failure': { 678 + reason: 'noValidNumbers' | 'networkError' | 'unknown' 679 + entryPoint: 'Onboarding' | 'Standalone' 680 + } 681 + // user followed a single match 682 + 'contacts:matches:follow': { 683 + entryPoint: 'Onboarding' | 'Standalone' 684 + } 685 + // user pressed "Follow All" on matches 686 + 'contacts:matches:followAll': { 687 + followCount: number 688 + entryPoint: 'Onboarding' | 'Standalone' 689 + } 690 + // user dismissed a match 691 + 'contacts:matches:dismiss': { 692 + entryPoint: 'Onboarding' | 'Standalone' 693 + } 694 + // user pressed invite to send an SMS to a non-match 695 + 'contacts:matches:invite': { 696 + entryPoint: 'Onboarding' | 'Standalone' 697 + } 698 + // user opened the Find Contacts settings screen 699 + 'contacts:settings:presented': { 700 + hasPreviouslySynced: boolean 701 + matchCount?: number 702 + } 703 + // user followed a single match from settings 704 + 'contacts:settings:follow': {} 705 + // user pressed "Follow All" from settings 706 + 'contacts:settings:followAll': { 707 + followCount: number 708 + } 709 + // user dismissed a match from settings 710 + 'contacts:settings:dismiss': {} 711 + // user re-entered the flow via the resync button 712 + 'contacts:settings:resync': { 713 + daysSinceLastSync: number 714 + } 715 + // user pressed the remove all data button 716 + 'contacts:settings:removeData': {} 628 717 }
+2
src/routes.ts
··· 69 69 '/settings/notifications/reposts-on-reposts', 70 70 ActivityNotificationSettings: '/settings/notifications/activity', 71 71 MiscellaneousNotificationSettings: '/settings/notifications/miscellaneous', 72 + FindContactsSettings: '/settings/find-contacts', 72 73 // support 73 74 Support: '/support', 74 75 PrivacyPolicy: '/support/privacy', ··· 91 92 StarterPackWizard: '/starter-pack/create', 92 93 VideoFeed: '/video-feed', 93 94 Bookmarks: '/saved', 95 + FindContactsFlow: '/find-contacts', 94 96 })
+77
src/screens/FindContactsFlowScreen.tsx
··· 1 + import {useCallback, useLayoutEffect, useState} from 'react' 2 + import {LayoutAnimationConfig} from 'react-native-reanimated' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {usePreventRemove} from '@react-navigation/native' 6 + 7 + import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 8 + import { 9 + type AllNavigatorParams, 10 + type NativeStackScreenProps, 11 + } from '#/lib/routes/types' 12 + import {isNative} from '#/platform/detection' 13 + import {useSetMinimalShellMode} from '#/state/shell' 14 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 15 + import {FindContactsFlow} from '#/components/contacts/FindContactsFlow' 16 + import {useFindContactsFlowState} from '#/components/contacts/state' 17 + import * as Layout from '#/components/Layout' 18 + import {ScreenTransition} from '#/components/ScreenTransition' 19 + 20 + type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsFlow'> 21 + export function FindContactsFlowScreen({navigation}: Props) { 22 + const {_} = useLingui() 23 + 24 + const [state, dispatch] = useFindContactsFlowState() 25 + 26 + const [transitionDirection, setTransitionDirection] = useState< 27 + 'Forward' | 'Backward' 28 + >('Forward') 29 + 30 + const overrideGoBack = state.step === '2: verify number' 31 + 32 + usePreventRemove(overrideGoBack, () => { 33 + setTransitionDirection('Backward') 34 + dispatch({type: 'BACK'}) 35 + setTimeout(() => { 36 + setTransitionDirection('Forward') 37 + }) 38 + }) 39 + 40 + useEnableKeyboardControllerScreen(true) 41 + 42 + const setMinimalShellMode = useSetMinimalShellMode() 43 + const effect = useCallback(() => { 44 + setMinimalShellMode(true) 45 + return () => setMinimalShellMode(false) 46 + }, [setMinimalShellMode]) 47 + useLayoutEffect(effect) 48 + 49 + return ( 50 + <Layout.Screen> 51 + {isNative ? ( 52 + <LayoutAnimationConfig skipEntering skipExiting> 53 + <ScreenTransition key={state.step} direction={transitionDirection}> 54 + <FindContactsFlow 55 + state={state} 56 + dispatch={dispatch} 57 + onCancel={() => 58 + navigation.canGoBack() 59 + ? navigation.goBack() 60 + : navigation.navigate('FindContactsFlow', undefined, { 61 + pop: true, 62 + }) 63 + } 64 + context="Standalone" 65 + /> 66 + </ScreenTransition> 67 + </LayoutAnimationConfig> 68 + ) : ( 69 + <ErrorScreen 70 + title={_(msg`Not available on this platform.`)} 71 + message={_(msg`Please use the native app to sync your contacts.`)} 72 + showHeader 73 + /> 74 + )} 75 + </Layout.Screen> 76 + ) 77 + }
+1 -1
src/screens/Messages/ChatList.tsx
··· 29 29 import {type DialogControlProps, useDialogControl} from '#/components/Dialog' 30 30 import {NewChat} from '#/components/dms/dialogs/NewChatDialog' 31 31 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 32 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 32 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 33 33 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 34 34 import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' 35 35 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+1 -1
src/screens/Messages/Inbox.tsx
··· 37 37 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 38 38 import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' 39 39 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 40 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 40 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 41 41 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 42 42 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' 43 43 import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+107 -95
src/screens/Onboarding/Layout.tsx
··· 1 - import React, {useState} from 'react' 1 + import {useEffect, useRef, useState} from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 - import {Dimensions} from 'react-native' 4 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 - import {msg} from '@lingui/macro' 4 + import {msg, Trans} from '@lingui/macro' 6 5 import {useLingui} from '@lingui/react' 7 6 8 7 import {isAndroid, isWeb} from '#/platform/detection' 9 8 import {useOnboardingDispatch} from '#/state/shell' 10 - import {Context} from '#/screens/Onboarding/state' 9 + import {useOnboardingInternalState} from '#/screens/Onboarding/state' 11 10 import { 12 11 atoms as a, 13 12 native, ··· 22 21 import {HEADER_SLOT_SIZE} from '#/components/Layout' 23 22 import {createPortalGroup} from '#/components/Portal' 24 23 import {P, Text} from '#/components/Typography' 24 + import {IS_INTERNAL} from '#/env' 25 25 26 26 const ONBOARDING_COL_WIDTH = 420 27 27 ··· 34 34 const insets = useSafeAreaInsets() 35 35 const {gtMobile} = useBreakpoints() 36 36 const onboardDispatch = useOnboardingDispatch() 37 - const {state, dispatch} = React.useContext(Context) 38 - const scrollview = React.useRef<ScrollView>(null) 39 - const prevActiveStep = React.useRef<string>(state.activeStep) 37 + const {state, dispatch} = useOnboardingInternalState() 38 + const scrollview = useRef<ScrollView>(null) 39 + const prevActiveStep = useRef<string>(state.activeStep) 40 40 41 - React.useEffect(() => { 41 + useEffect(() => { 42 42 if (state.activeStep !== prevActiveStep.current) { 43 43 prevActiveStep.current = state.activeStep 44 44 scrollview.current?.scrollTo({y: 0, animated: false}) 45 45 } 46 46 }, [state]) 47 47 48 - const paddingTop = gtMobile ? a.py_5xl : a.py_lg 49 48 const dialogLabel = _(msg`Set up your account`) 50 49 50 + const [headerHeight, setHeaderHeight] = useState(0) 51 51 const [footerHeight, setFooterHeight] = useState(0) 52 52 53 53 return ( ··· 59 59 accessibilityLabel={dialogLabel} 60 60 accessibilityHint={_(msg`Customizes your Bluesky experience`)} 61 61 style={[isWeb ? a.fixed : a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}> 62 - {__DEV__ && ( 63 - <Button 64 - variant="ghost" 65 - color="negative" 66 - size="tiny" 67 - onPress={() => onboardDispatch({type: 'skip'})} 68 - // DEV ONLY 69 - label="Clear onboarding state" 70 - style={[ 71 - a.absolute, 72 - a.z_10, 73 - { 74 - left: isWeb ? '50%' : Dimensions.get('window').width / 2 - 45, 75 - top: insets.top + 2, 76 - }, 77 - web({transform: [{translateX: '-50%'}]}), 78 - ]}> 79 - <ButtonText>[DEV] Clear</ButtonText> 80 - </Button> 81 - )} 82 - 83 - {!gtMobile && ( 62 + {!gtMobile ? ( 84 63 <View 85 - pointerEvents="box-none" 86 64 style={[ 87 65 web(a.fixed), 88 66 native(a.absolute), 67 + a.top_0, 89 68 a.left_0, 90 69 a.right_0, 91 70 a.flex_row, ··· 93 72 a.justify_center, 94 73 a.z_20, 95 74 a.px_xl, 96 - {top: paddingTop.paddingTop + insets.top - 1}, 97 - ]}> 75 + {paddingTop: (web(tokens.space.lg) ?? 0) + insets.top}, 76 + native([t.atoms.bg, a.pb_xs, {minHeight: 48}]), 77 + web(a.pointer_events_box_none), 78 + ]} 79 + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 98 80 <View 99 - pointerEvents="box-none" 100 81 style={[ 101 82 a.w_full, 102 - a.align_start, 83 + a.align_center, 103 84 a.flex_row, 104 85 a.justify_between, 105 - {maxWidth: ONBOARDING_COL_WIDTH}, 86 + web({maxWidth: ONBOARDING_COL_WIDTH}), 87 + web(a.pointer_events_box_none), 106 88 ]}> 107 - {state.hasPrev ? ( 89 + <HeaderSlot> 90 + {state.canGoBack && ( 91 + <Button 92 + key={state.activeStep} // remove focus state on nav 93 + color="secondary" 94 + variant="ghost" 95 + shape="round" 96 + size="small" 97 + label={_(msg`Go back to previous step`)} 98 + onPress={() => dispatch({type: 'prev'})}> 99 + <ButtonIcon icon={ArrowLeft} size="lg" /> 100 + </Button> 101 + )} 102 + </HeaderSlot> 103 + 104 + {IS_INTERNAL && ( 108 105 <Button 109 - key={state.activeStep} // remove focus state on nav 110 - color="secondary" 111 106 variant="ghost" 112 - shape="square" 113 - size="small" 114 - label={_(msg`Go back to previous step`)} 115 - onPress={() => dispatch({type: 'prev'})} 116 - style={[a.bg_transparent]}> 117 - <ButtonIcon icon={ArrowLeft} size="lg" /> 107 + color="negative" 108 + size="tiny" 109 + onPress={() => onboardDispatch({type: 'skip'})} 110 + // DEV ONLY 111 + label="Clear onboarding state"> 112 + <ButtonText>[DEV] Clear</ButtonText> 118 113 </Button> 119 - ) : ( 120 - <View /> 121 114 )} 122 115 123 - <OnboardingHeaderSlot.Outlet /> 116 + <HeaderSlot> 117 + <OnboardingHeaderSlot.Outlet /> 118 + </HeaderSlot> 124 119 </View> 125 120 </View> 121 + ) : ( 122 + <> 123 + {IS_INTERNAL && ( 124 + <View 125 + style={[ 126 + a.absolute, 127 + a.align_center, 128 + a.z_10, 129 + {top: 0, left: 0, right: 0}, 130 + ]}> 131 + <Button 132 + variant="ghost" 133 + color="negative" 134 + size="tiny" 135 + onPress={() => onboardDispatch({type: 'skip'})} 136 + // DEV ONLY 137 + label="Clear onboarding state"> 138 + <ButtonText>[DEV] Clear</ButtonText> 139 + </Button> 140 + </View> 141 + )} 142 + </> 126 143 )} 127 144 128 145 <ScrollView 129 146 ref={scrollview} 130 - style={[a.h_full, a.w_full, {paddingTop: insets.top}]} 131 - contentContainerStyle={{borderWidth: 0, minHeight: '100%'}} 147 + style={[a.h_full, a.w_full]} 148 + contentContainerStyle={{ 149 + borderWidth: 0, 150 + minHeight: '100%', 151 + paddingTop: gtMobile ? 40 : headerHeight, 152 + paddingBottom: footerHeight, 153 + }} 132 154 showsVerticalScrollIndicator={!isAndroid} 133 155 scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} 134 156 // @ts-expect-error web only --prf 135 - dataSet={{'stable-gutters': 1}}> 157 + dataSet={{'stable-gutters': 1}} 158 + centerContent={gtMobile}> 136 159 <View 137 160 style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> 138 - <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> 139 - <View style={[a.w_full, a.align_center, paddingTop]}> 140 - <View 141 - style={[ 142 - a.flex_row, 143 - a.gap_sm, 144 - a.w_full, 145 - a.align_center, 146 - {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, 147 - ]}> 148 - {Array(state.totalSteps) 149 - .fill(0) 150 - .map((__, i) => ( 151 - <View 152 - key={i} 153 - style={[ 154 - a.flex_1, 155 - a.pt_xs, 156 - a.rounded_full, 157 - t.atoms.bg_contrast_50, 158 - { 159 - backgroundColor: 160 - i + 1 <= state.activeStepIndex 161 - ? t.palette.primary_500 162 - : t.palette.contrast_100, 163 - }, 164 - ]} 165 - /> 166 - ))} 167 - </View> 168 - </View> 169 - 170 - <View style={[a.w_full, a.h_full, a.mb_5xl, a.pt_md]}> 171 - {children} 172 - </View> 173 - 174 - <View style={{height: 100 + footerHeight}} /> 161 + <View style={[a.flex_1, web({maxWidth: ONBOARDING_COL_WIDTH})]}> 162 + <View style={[a.w_full, a.py_md]}>{children}</View> 175 163 </View> 176 164 </View> 177 165 </ScrollView> ··· 200 188 gtMobile && [a.flex_row, a.justify_between, a.align_center], 201 189 ]}> 202 190 {gtMobile && 203 - (state.hasPrev ? ( 191 + (state.canGoBack ? ( 204 192 <Button 205 193 key={state.activeStep} // remove focus state on nav 206 194 color="secondary" ··· 221 209 ) 222 210 } 223 211 224 - export function TitleText({ 212 + function HeaderSlot({children}: {children?: React.ReactNode}) { 213 + return ( 214 + <View style={[{minHeight: HEADER_SLOT_SIZE, minWidth: HEADER_SLOT_SIZE}]}> 215 + {children} 216 + </View> 217 + ) 218 + } 219 + 220 + export function OnboardingPosition() { 221 + const {state} = useOnboardingInternalState() 222 + const t = useTheme() 223 + 224 + return ( 225 + <Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}> 226 + <Trans> 227 + Step {state.activeStepIndex + 1} of {state.totalSteps} 228 + </Trans> 229 + </Text> 230 + ) 231 + } 232 + 233 + export function OnboardingTitleText({ 225 234 children, 226 235 style, 227 236 }: React.PropsWithChildren<TextStyleProp>) { 228 237 return ( 229 - <Text 230 - style={[a.pb_sm, a.text_4xl, a.font_semi_bold, a.leading_tight, style]}> 238 + <Text style={[a.text_3xl, a.font_bold, a.leading_snug, style]}> 231 239 {children} 232 240 </Text> 233 241 ) 234 242 } 235 243 236 - export function DescriptionText({ 244 + export function OnboardingDescriptionText({ 237 245 children, 238 246 style, 239 247 }: React.PropsWithChildren<TextStyleProp>) { 240 248 const t = useTheme() 241 - return <P style={[t.atoms.text_contrast_medium, style]}>{children}</P> 249 + return ( 250 + <P style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium, style]}> 251 + {children} 252 + </P> 253 + ) 242 254 }
+60
src/screens/Onboarding/StepFindContacts/index.tsx
··· 1 + import {useCallback, useState} from 'react' 2 + import {LayoutAnimationConfig} from 'react-native-reanimated' 3 + import {SafeAreaView} from 'react-native-safe-area-context' 4 + 5 + import {logger} from '#/logger' 6 + import {FindContactsFlow} from '#/components/contacts/FindContactsFlow' 7 + import {type Action, type State} from '#/components/contacts/state' 8 + import {ScreenTransition} from '#/components/ScreenTransition' 9 + import {useOnboardingInternalState} from '../state' 10 + 11 + export function StepFindContacts({ 12 + flowState, 13 + flowDispatch, 14 + }: { 15 + flowState: State 16 + flowDispatch: React.ActionDispatch<[Action]> 17 + }) { 18 + const {dispatch} = useOnboardingInternalState() 19 + 20 + const [transitionDirection, setTransitionDirection] = useState< 21 + 'Forward' | 'Backward' 22 + >('Forward') 23 + 24 + const isFinalStep = flowState.step === '4: view matches' 25 + const onSkip = useCallback(() => { 26 + if (!isFinalStep) { 27 + logger.metric('onboarding:contacts:skipPressed', {}) 28 + } 29 + dispatch({type: 'next'}) 30 + }, [dispatch, isFinalStep]) 31 + 32 + const canGoBack = flowState.step === '2: verify number' 33 + const onBack = useCallback(() => { 34 + if (canGoBack) { 35 + setTransitionDirection('Backward') 36 + flowDispatch({type: 'BACK'}) 37 + setTimeout(() => { 38 + setTransitionDirection('Forward') 39 + }) 40 + } else { 41 + dispatch({type: 'prev'}) 42 + } 43 + }, [dispatch, flowDispatch, canGoBack]) 44 + 45 + return ( 46 + <SafeAreaView edges={['left', 'top', 'right']}> 47 + <LayoutAnimationConfig skipEntering skipExiting> 48 + <ScreenTransition key={flowState.step} direction={transitionDirection}> 49 + <FindContactsFlow 50 + context="Onboarding" 51 + state={flowState} 52 + dispatch={flowDispatch} 53 + onCancel={onSkip} 54 + onBack={onBack} 55 + /> 56 + </ScreenTransition> 57 + </LayoutAnimationConfig> 58 + </SafeAreaView> 59 + ) 60 + }
+3
src/screens/Onboarding/StepFindContacts/index.web.tsx
··· 1 + export function StepFindContacts() { 2 + throw new Error('StepFindContacts is not available on web') 3 + }
+88
src/screens/Onboarding/StepFindContactsIntro/index.tsx
··· 1 + import {View} from 'react-native' 2 + import * as Contacts from 'expo-contacts' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useQuery} from '@tanstack/react-query' 6 + 7 + import {atoms as a} from '#/alf' 8 + import {Admonition} from '#/components/Admonition' 9 + import {Button, ButtonText} from '#/components/Button' 10 + import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' 11 + import {InlineLinkText} from '#/components/Link' 12 + import { 13 + OnboardingControls, 14 + OnboardingDescriptionText, 15 + OnboardingPosition, 16 + OnboardingTitleText, 17 + } from '../Layout' 18 + import {useOnboardingInternalState} from '../state' 19 + 20 + export function StepFindContactsIntro() { 21 + const {_} = useLingui() 22 + const {dispatch} = useOnboardingInternalState() 23 + 24 + const {data: isAvailable, isSuccess} = useQuery({ 25 + queryKey: ['contacts-available'], 26 + queryFn: async () => await Contacts.isAvailableAsync(), 27 + }) 28 + 29 + return ( 30 + <View style={[a.w_full, a.gap_sm]}> 31 + <OnboardingPosition /> 32 + <ContactsHeroImage /> 33 + <OnboardingTitleText style={[a.mt_sm]}> 34 + <Trans>Bluesky is more fun with friends</Trans> 35 + </OnboardingTitleText> 36 + <OnboardingDescriptionText> 37 + <Trans> 38 + Find your friends on Bluesky by verifying your phone number and 39 + matching with your contacts. We protect your information and you 40 + control what happens next.{' '} 41 + <InlineLinkText 42 + to="#" 43 + label={_( 44 + msg({ 45 + message: `Learn more about importing contacts`, 46 + context: `english-only-resource`, 47 + }), 48 + )} 49 + style={[a.text_md, a.leading_snug]}> 50 + <Trans context="english-only-resource">Learn more</Trans> 51 + </InlineLinkText> 52 + </Trans> 53 + </OnboardingDescriptionText> 54 + {!isAvailable && isSuccess && ( 55 + <Admonition type="error"> 56 + <Trans> 57 + Contact sync is not available on this device, as the app is unable 58 + to access your contacts. 59 + </Trans> 60 + </Admonition> 61 + )} 62 + 63 + <OnboardingControls.Portal> 64 + <View style={[a.gap_md]}> 65 + <Button 66 + onPress={() => dispatch({type: 'next'})} 67 + label={_(msg`Import contacts`)} 68 + size="large" 69 + color="primary" 70 + disabled={!isAvailable}> 71 + <ButtonText> 72 + <Trans>Import contacts</Trans> 73 + </ButtonText> 74 + </Button> 75 + <Button 76 + onPress={() => dispatch({type: 'skip-contacts'})} 77 + label={_(msg`Skip`)} 78 + size="large" 79 + color="secondary"> 80 + <ButtonText> 81 + <Trans>Skip</Trans> 82 + </ButtonText> 83 + </Button> 84 + </View> 85 + </OnboardingControls.Portal> 86 + </View> 87 + ) 88 + }
+3
src/screens/Onboarding/StepFindContactsIntro/index.web.tsx
··· 1 + export function StepFindContactsIntro() { 2 + throw new Error('StepFindContactsIntro is not available on web') 3 + }
+8 -106
src/screens/Onboarding/StepFinished/index.tsx
··· 1 - import {useCallback, useContext, useState} from 'react' 1 + import {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyActorDefs, ··· 35 35 useSetActiveStarterPack, 36 36 } from '#/state/shell/starter-pack' 37 37 import { 38 - DescriptionText, 39 38 OnboardingControls, 40 39 OnboardingHeaderSlot, 41 - TitleText, 42 40 } from '#/screens/Onboarding/Layout' 43 - import {Context, type OnboardingState} from '#/screens/Onboarding/state' 41 + import { 42 + type OnboardingState, 43 + useOnboardingInternalState, 44 + } from '#/screens/Onboarding/state' 44 45 import {bulkWriteFollows} from '#/screens/Onboarding/util' 45 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 46 + import {atoms as a, useBreakpoints} from '#/alf' 46 47 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 47 - import {IconCircle} from '#/components/IconCircle' 48 48 import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 49 - import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 50 - import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 51 - import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' 52 - import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending' 53 49 import {Loader} from '#/components/Loader' 54 - import {Text} from '#/components/Typography' 55 50 import * as bsky from '#/types/bsky' 56 51 import {ValuePropositionPager} from './ValuePropositionPager' 57 52 58 53 export function StepFinished() { 59 - const {state, dispatch} = useContext(Context) 54 + const {state, dispatch} = useOnboardingInternalState() 60 55 const onboardDispatch = useOnboardingDispatch() 61 56 const [saving, setSaving] = useState(false) 62 57 const queryClient = useQueryClient() ··· 247 242 gate, 248 243 ]) 249 244 250 - return state.experiments?.onboarding_value_prop ? ( 245 + return ( 251 246 <ValueProposition 252 - finishOnboarding={finishOnboarding} 253 - saving={saving} 254 - state={state} 255 - /> 256 - ) : ( 257 - <LegacyFinalStep 258 247 finishOnboarding={finishOnboarding} 259 248 saving={saving} 260 249 state={state} ··· 359 348 </> 360 349 ) 361 350 } 362 - 363 - function LegacyFinalStep({ 364 - finishOnboarding, 365 - saving, 366 - state, 367 - }: { 368 - finishOnboarding: () => void 369 - saving: boolean 370 - state: OnboardingState 371 - }) { 372 - const t = useTheme() 373 - const {_} = useLingui() 374 - 375 - return ( 376 - <View style={[a.align_start]}> 377 - <IconCircle icon={Check} style={[a.mb_2xl]} /> 378 - 379 - <TitleText> 380 - <Trans>You're ready to go!</Trans> 381 - </TitleText> 382 - <DescriptionText> 383 - <Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans> 384 - </DescriptionText> 385 - 386 - <View style={[a.pt_5xl, a.gap_3xl]}> 387 - <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 388 - <IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} /> 389 - <View style={[a.flex_1, a.gap_xs]}> 390 - <Text style={[a.font_semi_bold, a.text_lg]}> 391 - <Trans>Public</Trans> 392 - </Text> 393 - <Text 394 - style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}> 395 - <Trans> 396 - Your posts, likes, and blocks are public. Mutes are private. 397 - </Trans> 398 - </Text> 399 - </View> 400 - </View> 401 - <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 402 - <IconCircle icon={News} size="lg" style={{width: 48, height: 48}} /> 403 - <View style={[a.flex_1, a.gap_xs]}> 404 - <Text style={[a.font_semi_bold, a.text_lg]}> 405 - <Trans>Open</Trans> 406 - </Text> 407 - <Text 408 - style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}> 409 - <Trans>Never lose access to your followers or data.</Trans> 410 - </Text> 411 - </View> 412 - </View> 413 - <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 414 - <IconCircle 415 - icon={Trending} 416 - size="lg" 417 - style={{width: 48, height: 48}} 418 - /> 419 - <View style={[a.flex_1, a.gap_xs]}> 420 - <Text style={[a.font_semi_bold, a.text_lg]}> 421 - <Trans>Flexible</Trans> 422 - </Text> 423 - <Text 424 - style={[t.atoms.text_contrast_medium, a.text_md, a.leading_snug]}> 425 - <Trans>Choose the algorithms that power your custom feeds.</Trans> 426 - </Text> 427 - </View> 428 - </View> 429 - </View> 430 - 431 - <OnboardingControls.Portal> 432 - <Button 433 - testID="onboardingFinish" 434 - disabled={saving} 435 - key={state.activeStep} // remove focus state on nav 436 - color="primary" 437 - size="large" 438 - label={_(msg`Complete onboarding and start using your account`)} 439 - onPress={finishOnboarding}> 440 - <ButtonText> 441 - {saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>} 442 - </ButtonText> 443 - {saving && <ButtonIcon icon={Loader} position="right" />} 444 - </Button> 445 - </OnboardingControls.Portal> 446 - </View> 447 - ) 448 - }
+13 -16
src/screens/Onboarding/StepInterests/index.tsx
··· 8 8 import {capitalize} from '#/lib/strings/capitalize' 9 9 import {logger} from '#/logger' 10 10 import { 11 - DescriptionText, 12 11 OnboardingControls, 13 - TitleText, 12 + OnboardingDescriptionText, 13 + OnboardingPosition, 14 + OnboardingTitleText, 14 15 } from '#/screens/Onboarding/Layout' 15 - import {Context} from '#/screens/Onboarding/state' 16 + import {useOnboardingInternalState} from '#/screens/Onboarding/state' 16 17 import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' 17 18 import {atoms as a} from '#/alf' 18 19 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 20 import * as Toggle from '#/components/forms/Toggle' 20 - import {IconCircle} from '#/components/IconCircle' 21 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 22 - import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 23 21 import {Loader} from '#/components/Loader' 24 22 25 23 export function StepInterests() { 26 24 const {_} = useLingui() 27 25 const interestsDisplayNames = useInterestsDisplayNames() 28 26 29 - const {state, dispatch} = React.useContext(Context) 27 + const {state, dispatch} = useOnboardingInternalState() 30 28 const [saving, setSaving] = React.useState(false) 31 29 const [selectedInterests, setSelectedInterests] = React.useState<string[]>( 32 30 state.interestsStepResults.selectedInterests.map(i => i), ··· 53 51 }, [selectedInterests, setSaving, dispatch]) 54 52 55 53 return ( 56 - <View style={[a.align_start]} testID="onboardingInterests"> 57 - <IconCircle icon={Hashtag} style={[a.mb_2xl]} /> 58 - 59 - <TitleText> 54 + <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests"> 55 + <OnboardingPosition /> 56 + <OnboardingTitleText> 60 57 <Trans>What are your interests?</Trans> 61 - </TitleText> 62 - <DescriptionText> 58 + </OnboardingTitleText> 59 + <OnboardingDescriptionText> 63 60 <Trans>We'll use this to help customize your experience.</Trans> 64 - </DescriptionText> 61 + </OnboardingDescriptionText> 65 62 66 - <View style={[a.w_full, a.pt_2xl]}> 63 + <View style={[a.w_full, a.pt_lg]}> 67 64 <Toggle.Group 68 65 values={selectedInterests} 69 66 onChange={setSelectedInterests} ··· 93 90 <ButtonText> 94 91 <Trans>Continue</Trans> 95 92 </ButtonText> 96 - <ButtonIcon icon={saving ? Loader : ChevronRight} /> 93 + {saving && <ButtonIcon icon={Loader} />} 97 94 </Button> 98 95 </OnboardingControls.Portal> 99 96 </View>
+21 -25
src/screens/Onboarding/StepProfile/index.tsx
··· 19 19 import {logger} from '#/logger' 20 20 import {isNative, isWeb} from '#/platform/detection' 21 21 import { 22 - DescriptionText, 23 22 OnboardingControls, 24 - TitleText, 23 + OnboardingDescriptionText, 24 + OnboardingPosition, 25 + OnboardingTitleText, 25 26 } from '#/screens/Onboarding/Layout' 26 - import {Context} from '#/screens/Onboarding/state' 27 + import {useOnboardingInternalState} from '#/screens/Onboarding/state' 27 28 import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' 28 29 import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' 29 30 import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' ··· 32 33 type PlaceholderCanvasRef, 33 34 } from '#/screens/Onboarding/StepProfile/PlaceholderCanvas' 34 35 import {atoms as a, useBreakpoints, useTheme} from '#/alf' 35 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 36 + import {Button, ButtonText} from '#/components/Button' 36 37 import * as Dialog from '#/components/Dialog' 37 38 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 38 - import {IconCircle} from '#/components/IconCircle' 39 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 40 39 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' 41 - import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive' 42 40 import {Text} from '#/components/Typography' 43 41 import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types' 44 42 ··· 78 76 const creatorControl = Dialog.useDialogControl() 79 77 const [error, setError] = React.useState('') 80 78 81 - const {state, dispatch} = React.useContext(Context) 79 + const {state, dispatch} = useOnboardingInternalState() 82 80 const [avatar, setAvatar] = React.useState<Avatar>({ 83 81 image: state.profileStepResults?.image, 84 82 placeholder: state.profileStepResults.creatorState?.emoji || emojiItems.at, ··· 237 235 238 236 return ( 239 237 <AvatarContext.Provider value={value}> 240 - <View style={[a.align_start, t.atoms.bg, a.justify_between]}> 241 - <IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> 242 - <TitleText> 243 - <Trans>Give your profile a face</Trans> 244 - </TitleText> 245 - <DescriptionText> 246 - <Trans> 247 - Help people know you're not a bot by uploading a picture or creating 248 - an avatar. 249 - </Trans> 250 - </DescriptionText> 238 + <View style={[a.align_start]}> 239 + <View style={[a.gap_sm]}> 240 + <OnboardingPosition /> 241 + <OnboardingTitleText> 242 + <Trans>Give your profile a face</Trans> 243 + </OnboardingTitleText> 244 + <OnboardingDescriptionText> 245 + <Trans> 246 + Help people know you're not a bot by uploading a picture or 247 + creating an avatar. 248 + </Trans> 249 + </OnboardingDescriptionText> 250 + </View> 251 251 <View 252 - style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 40}]}> 252 + style={[a.w_full, a.align_center, {paddingTop: gtMobile ? 80 : 60}]}> 253 253 <AvatarCircle 254 254 openLibrary={openLibrary} 255 255 openCreator={creatorControl.open} ··· 279 279 <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> 280 280 <Button 281 281 testID="onboardingContinue" 282 - variant="solid" 283 282 color="primary" 284 283 size="large" 285 284 label={_(msg`Continue to next step`)} ··· 287 286 <ButtonText> 288 287 <Trans>Continue</Trans> 289 288 </ButtonText> 290 - <ButtonIcon icon={ChevronRight} position="right" /> 291 289 </Button> 292 290 <Button 293 291 testID="onboardingAvatarCreator" 294 - variant="ghost" 295 - color="primary" 292 + color="primary_subtle" 296 293 size="large" 297 294 label={_(msg`Open avatar creator`)} 298 295 onPress={onSecondaryPress}> ··· 335 332 </View> 336 333 <View style={[a.pt_4xl]}> 337 334 <Button 338 - variant="solid" 339 335 color="primary" 340 336 size="large" 341 337 label={_(msg`Done`)}
+18 -19
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 1 - import { 2 - useCallback, 3 - useContext, 4 - useEffect, 5 - useMemo, 6 - useRef, 7 - useState, 8 - } from 'react' 1 + import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 9 2 import {View} from 'react-native' 10 3 import {type ModerationOpts} from '@atproto/api' 11 4 import {msg, Trans} from '@lingui/macro' ··· 22 15 import {useLanguagePrefs} from '#/state/preferences' 23 16 import {useModerationOpts} from '#/state/preferences/moderation-opts' 24 17 import {useAgent, useSession} from '#/state/session' 25 - import {OnboardingControls} from '#/screens/Onboarding/Layout' 26 - import {Context} from '#/screens/Onboarding/state' 18 + import { 19 + OnboardingControls, 20 + OnboardingPosition, 21 + OnboardingTitleText, 22 + } from '#/screens/Onboarding/Layout' 23 + import {useOnboardingInternalState} from '#/screens/Onboarding/state' 27 24 import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 28 - import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 25 + import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 29 26 import {Admonition} from '#/components/Admonition' 30 27 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 31 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' 28 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate' 32 29 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 33 30 import {boostInterests, InterestTabs} from '#/components/InterestTabs' 34 31 import {Loader} from '#/components/Loader' 35 32 import * as ProfileCard from '#/components/ProfileCard' 36 33 import * as toast from '#/components/Toast' 37 - import {Text} from '#/components/Typography' 38 34 import type * as bsky from '#/types/bsky' 39 35 import {bulkWriteFollows} from '../util' 40 36 ··· 47 43 const {currentAccount} = useSession() 48 44 const queryClient = useQueryClient() 49 45 50 - const {state, dispatch} = useContext(Context) 46 + const {state, dispatch} = useOnboardingInternalState() 51 47 52 48 const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 53 49 // keeping track of who was followed via the follow all button ··· 153 149 ) 154 150 155 151 return ( 156 - <View style={[a.align_start]} testID="onboardingInterests"> 157 - <Text style={[a.font_bold, a.text_3xl]}> 152 + <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests"> 153 + <OnboardingPosition /> 154 + <OnboardingTitleText> 158 155 <Trans comment="Accounts suggested to the user for them to follow"> 159 156 Suggested for you 160 157 </Trans> 161 - </Text> 158 + </OnboardingTitleText> 162 159 163 160 <View 164 161 style={[ 165 162 a.overflow_hidden, 166 - a.mt_lg, 167 - isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, 163 + a.mt_sm, 164 + isWeb 165 + ? [a.max_w_full, web({minHeight: '100vh'})] 166 + : {marginHorizontal: tokens.space.xl * -1}, 168 167 a.flex_1, 169 168 a.justify_start, 170 169 ]}>
+16 -14
src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx
··· 1 - import {useContext} from 'react' 2 1 import {View} from 'react-native' 3 2 import {msg, Trans} from '@lingui/macro' 4 3 import {useLingui} from '@lingui/react' 5 4 6 5 import {useModerationOpts} from '#/state/preferences/moderation-opts' 7 6 import {useOnboardingSuggestedStarterPacksQuery} from '#/state/queries/useOnboardingSuggestedStarterPacksQuery' 8 - import {OnboardingControls} from '#/screens/Onboarding/Layout' 9 - import {Context} from '#/screens/Onboarding/state' 7 + import { 8 + OnboardingControls, 9 + OnboardingPosition, 10 + OnboardingTitleText, 11 + } from '#/screens/Onboarding/Layout' 12 + import {useOnboardingInternalState} from '#/screens/Onboarding/state' 10 13 import {atoms as a, useBreakpoints} from '#/alf' 11 14 import {Admonition} from '#/components/Admonition' 12 15 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 13 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' 16 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate' 14 17 import {Loader} from '#/components/Loader' 15 - import {Text} from '#/components/Typography' 16 18 import {StarterPackCard} from './StarterPackCard' 17 19 18 20 export function StepSuggestedStarterpacks() { ··· 20 22 const {gtMobile} = useBreakpoints() 21 23 const moderationOpts = useModerationOpts() 22 24 23 - const {state, dispatch} = useContext(Context) 25 + const {state, dispatch} = useOnboardingInternalState() 24 26 25 27 const { 26 28 data: suggestedStarterPacks, ··· 34 36 }) 35 37 36 38 return ( 37 - <View style={[a.align_start]} testID="onboardingInterests"> 38 - <Text style={[a.font_bold, a.text_3xl]}> 39 - <Trans comment="Accounts suggested to the user for them to follow"> 40 - Suggested for you 39 + <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests"> 40 + <OnboardingPosition /> 41 + <OnboardingTitleText> 42 + <Trans comment="Starter packs suggested to the user for them to follow"> 43 + Find people to follow 41 44 </Trans> 42 - </Text> 45 + </OnboardingTitleText> 43 46 44 47 <View 45 48 style={[ 46 49 a.overflow_hidden, 47 - a.mt_lg, 48 50 a.flex_1, 49 51 a.justify_start, 50 52 a.w_full, 53 + a.mt_sm, 51 54 ]}> 52 55 {isLoading || !moderationOpts ? ( 53 56 <View 54 57 style={[ 55 58 a.flex_1, 56 - a.mt_md, 57 59 a.align_center, 58 60 a.justify_center, 59 61 {minHeight: 400}, ··· 69 71 </Admonition> 70 72 </View> 71 73 ) : ( 72 - <View style={[a.flex_1, a.mt_md]}> 74 + <View style={[a.flex_1]}> 73 75 {suggestedStarterPacks?.starterPacks.map(starterPack => ( 74 76 <View style={[a.pb_lg]} key={starterPack.uri}> 75 77 <StarterPackCard view={starterPack} />
+60 -59
src/screens/Onboarding/index.tsx
··· 1 1 import {useMemo, useReducer} from 'react' 2 - import {msg} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 2 + import {View} from 'react-native' 4 3 import * as bcp47Match from 'bcp-47-match' 5 4 5 + import {useEnableKeyboardControllerScreen} from '#/lib/hooks/useEnableKeyboardController' 6 6 import {useGate} from '#/lib/statsig/statsig' 7 + import {isNative} from '#/platform/detection' 7 8 import {useLanguagePrefs} from '#/state/preferences' 8 9 import { 9 10 Layout, 10 11 OnboardingControls, 11 12 OnboardingHeaderSlot, 12 13 } from '#/screens/Onboarding/Layout' 13 - import {Context, initialState, reducer} from '#/screens/Onboarding/state' 14 + import { 15 + Context, 16 + createInitialOnboardingState, 17 + reducer, 18 + } from '#/screens/Onboarding/state' 14 19 import {StepFinished} from '#/screens/Onboarding/StepFinished' 15 20 import {StepInterests} from '#/screens/Onboarding/StepInterests' 16 21 import {StepProfile} from '#/screens/Onboarding/StepProfile' 22 + import {atoms as a, useTheme} from '#/alf' 23 + import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 24 + import {useFindContactsFlowState} from '#/components/contacts/state' 17 25 import {Portal} from '#/components/Portal' 18 26 import {ScreenTransition} from '#/components/ScreenTransition' 19 27 import {ENV} from '#/env' 28 + import {StepFindContacts} from './StepFindContacts' 29 + import {StepFindContactsIntro} from './StepFindContactsIntro' 20 30 import {StepSuggestedAccounts} from './StepSuggestedAccounts' 21 31 import {StepSuggestedStarterpacks} from './StepSuggestedStarterpacks' 22 32 23 33 export function Onboarding() { 24 - const {_} = useLingui() 25 34 const gate = useGate() 35 + const t = useTheme() 26 36 27 37 const {contentLanguages} = useLanguagePrefs() 28 38 const probablySpeaksEnglish = useMemo(() => { ··· 36 46 probablySpeaksEnglish && 37 47 gate('onboarding_suggested_starterpacks') 38 48 39 - const [state, dispatch] = useReducer(reducer, { 40 - ...initialState, 41 - totalSteps: 4 + (showSuggestedStarterpacks ? 1 : 0), 42 - experiments: { 43 - onboarding_suggested_accounts: true, 44 - onboarding_value_prop: true, 45 - onboarding_suggested_starterpacks: showSuggestedStarterpacks, 49 + const findContactsEnabled = 50 + useIsFindContactsFeatureEnabledBasedOnGeolocation() 51 + const showFindContacts = 52 + isNative && findContactsEnabled && !gate('disable_onboarding_find_contacts') 53 + 54 + const [state, dispatch] = useReducer( 55 + reducer, 56 + { 57 + starterPacksStepEnabled: showSuggestedStarterpacks, 58 + findContactsStepEnabled: showFindContacts, 46 59 }, 47 - }) 60 + createInitialOnboardingState, 61 + ) 62 + const [contactsFlowState, contactsFlowDispatch] = useFindContactsFlowState() 48 63 49 - const interestsDisplayNames = useMemo(() => { 50 - return { 51 - news: _(msg`News`), 52 - journalism: _(msg`Journalism`), 53 - nature: _(msg`Nature`), 54 - art: _(msg`Art`), 55 - comics: _(msg`Comics`), 56 - writers: _(msg`Writers`), 57 - culture: _(msg`Culture`), 58 - sports: _(msg`Sports`), 59 - pets: _(msg`Pets`), 60 - animals: _(msg`Animals`), 61 - books: _(msg`Books`), 62 - education: _(msg`Education`), 63 - climate: _(msg`Climate`), 64 - science: _(msg`Science`), 65 - politics: _(msg`Politics`), 66 - fitness: _(msg`Fitness`), 67 - tech: _(msg`Tech`), 68 - dev: _(msg`Software Dev`), 69 - comedy: _(msg`Comedy`), 70 - gaming: _(msg`Video Games`), 71 - food: _(msg`Food`), 72 - cooking: _(msg`Cooking`), 73 - } 74 - }, [_]) 64 + useEnableKeyboardControllerScreen(true) 75 65 76 66 return ( 77 67 <Portal> 78 - <OnboardingControls.Provider> 79 - <OnboardingHeaderSlot.Provider> 80 - <Context.Provider 81 - value={useMemo( 82 - () => ({state, dispatch, interestsDisplayNames}), 83 - [state, dispatch, interestsDisplayNames], 84 - )}> 85 - <Layout> 68 + <View style={[a.absolute, a.inset_0, t.atoms.bg]}> 69 + <OnboardingControls.Provider> 70 + <OnboardingHeaderSlot.Provider> 71 + <Context.Provider 72 + value={useMemo(() => ({state, dispatch}), [state, dispatch])}> 86 73 <ScreenTransition 87 74 key={state.activeStep} 88 - direction={state.stepTransitionDirection}> 89 - {state.activeStep === 'profile' && <StepProfile />} 90 - {state.activeStep === 'interests' && <StepInterests />} 91 - {state.activeStep === 'suggested-accounts' && ( 92 - <StepSuggestedAccounts /> 75 + direction={state.stepTransitionDirection} 76 + style={a.flex_1}> 77 + {/* FindContactsFlow cannot be nested in Layout */} 78 + {state.activeStep === 'find-contacts' ? ( 79 + <StepFindContacts 80 + flowState={contactsFlowState} 81 + flowDispatch={contactsFlowDispatch} 82 + /> 83 + ) : ( 84 + <Layout> 85 + {state.activeStep === 'profile' && <StepProfile />} 86 + {state.activeStep === 'interests' && <StepInterests />} 87 + {state.activeStep === 'suggested-accounts' && ( 88 + <StepSuggestedAccounts /> 89 + )} 90 + {state.activeStep === 'suggested-starterpacks' && ( 91 + <StepSuggestedStarterpacks /> 92 + )} 93 + {state.activeStep === 'find-contacts-intro' && ( 94 + <StepFindContactsIntro /> 95 + )} 96 + {state.activeStep === 'finished' && <StepFinished />} 97 + </Layout> 93 98 )} 94 - {state.activeStep === 'suggested-starterpacks' && ( 95 - <StepSuggestedStarterpacks /> 96 - )} 97 - {state.activeStep === 'finished' && <StepFinished />} 98 99 </ScreenTransition> 99 - </Layout> 100 - </Context.Provider> 101 - </OnboardingHeaderSlot.Provider> 102 - </OnboardingControls.Provider> 100 + </Context.Provider> 101 + </OnboardingHeaderSlot.Provider> 102 + </OnboardingControls.Provider> 103 + </View> 103 104 </Portal> 104 105 ) 105 106 }
+126 -54
src/screens/Onboarding/state.ts
··· 1 - import React from 'react' 1 + import {createContext, useContext, useMemo} from 'react' 2 2 3 3 import {logger} from '#/logger' 4 4 import { 5 5 type AvatarColor, 6 6 type Emoji, 7 7 } from '#/screens/Onboarding/StepProfile/types' 8 + 9 + type OnboardingScreen = 10 + | 'profile' 11 + | 'interests' 12 + | 'suggested-accounts' 13 + | 'suggested-starterpacks' 14 + | 'find-contacts-intro' 15 + | 'find-contacts' 16 + | 'finished' 8 17 9 18 export type OnboardingState = { 10 - hasPrev: boolean 11 - totalSteps: number 12 - activeStep: 13 - | 'profile' 14 - | 'interests' 15 - | 'suggested-accounts' 16 - | 'suggested-starterpacks' 17 - | 'finished' 18 - activeStepIndex: number 19 + screens: Record<OnboardingScreen, boolean> 20 + activeStep: OnboardingScreen 19 21 stepTransitionDirection: 'Forward' | 'Backward' 20 22 21 23 interestsStepResults: { ··· 37 39 backgroundColor: AvatarColor 38 40 } 39 41 } 40 - 41 - experiments?: { 42 - onboarding_suggested_accounts?: boolean 43 - onboarding_value_prop?: boolean 44 - onboarding_suggested_starterpacks?: boolean 45 - } 46 42 } 47 43 48 44 export type OnboardingAction = ··· 51 47 } 52 48 | { 53 49 type: 'prev' 50 + } 51 + | { 52 + type: 'skip-contacts' 54 53 } 55 54 | { 56 55 type: 'finish' ··· 73 72 | undefined 74 73 } 75 74 76 - export const initialState: OnboardingState = { 77 - hasPrev: false, 78 - totalSteps: 3, 79 - activeStep: 'profile', 80 - activeStepIndex: 1, 81 - stepTransitionDirection: 'Forward', 75 + export function createInitialOnboardingState( 76 + { 77 + starterPacksStepEnabled, 78 + findContactsStepEnabled, 79 + }: { 80 + starterPacksStepEnabled: boolean 81 + findContactsStepEnabled: boolean 82 + } = {starterPacksStepEnabled: true, findContactsStepEnabled: false}, 83 + ): OnboardingState { 84 + const screens: OnboardingState['screens'] = { 85 + profile: true, 86 + interests: true, 87 + 'suggested-accounts': true, 88 + 'suggested-starterpacks': starterPacksStepEnabled, 89 + 'find-contacts-intro': findContactsStepEnabled, 90 + 'find-contacts': findContactsStepEnabled, 91 + finished: true, 92 + } 82 93 83 - interestsStepResults: { 84 - selectedInterests: [], 85 - }, 86 - profileStepResults: { 87 - isCreatedAvatar: false, 88 - image: undefined, 89 - imageUri: '', 90 - imageMime: '', 91 - }, 94 + return { 95 + screens, 96 + activeStep: 'profile', 97 + stepTransitionDirection: 'Forward', 98 + interestsStepResults: { 99 + selectedInterests: [], 100 + }, 101 + profileStepResults: { 102 + isCreatedAvatar: false, 103 + image: undefined, 104 + imageUri: '', 105 + imageMime: '', 106 + }, 107 + } 92 108 } 93 109 94 - export const Context = React.createContext<{ 110 + export const Context = createContext<{ 95 111 state: OnboardingState 96 112 dispatch: React.Dispatch<OnboardingAction> 97 - }>({ 98 - state: {...initialState}, 99 - dispatch: () => {}, 100 - }) 113 + } | null>(null) 101 114 Context.displayName = 'OnboardingContext' 102 115 103 116 export function reducer( ··· 106 119 ): OnboardingState { 107 120 let next = {...s} 108 121 109 - const stepOrder: OnboardingState['activeStep'][] = [ 110 - 'profile', 111 - 'interests', 112 - ...(s.experiments?.onboarding_suggested_accounts 113 - ? (['suggested-accounts'] as const) 114 - : []), 115 - ...(s.experiments?.onboarding_suggested_starterpacks 116 - ? (['suggested-starterpacks'] as const) 117 - : []), 118 - 'finished', 119 - ] 122 + const stepOrder = getStepOrder(s) 120 123 121 124 switch (a.type) { 122 125 case 'next': { 123 - // 1-indexed for some reason 124 - const nextIndex = s.activeStepIndex 126 + const nextIndex = stepOrder.indexOf(next.activeStep) + 1 125 127 const nextStep = stepOrder[nextIndex] 126 128 if (nextStep) { 127 129 next.activeStep = nextStep 128 - next.activeStepIndex = nextIndex + 1 129 130 } 130 131 next.stepTransitionDirection = 'Forward' 131 132 break 132 133 } 133 134 case 'prev': { 134 - const prevIndex = s.activeStepIndex - 2 135 + const prevIndex = stepOrder.indexOf(next.activeStep) - 1 135 136 const prevStep = stepOrder[prevIndex] 136 137 if (prevStep) { 137 138 next.activeStep = prevStep 138 - next.activeStepIndex = prevIndex + 1 139 139 } 140 140 next.stepTransitionDirection = 'Backward' 141 141 break 142 142 } 143 + case 'skip-contacts': { 144 + const nextIndex = stepOrder.indexOf('find-contacts') + 1 145 + const nextStep = stepOrder[nextIndex] ?? 'finished' 146 + next.activeStep = nextStep 147 + next.stepTransitionDirection = 'Forward' 148 + break 149 + } 143 150 case 'finish': { 144 - next = initialState 151 + next = createInitialOnboardingState({ 152 + starterPacksStepEnabled: s.screens['suggested-starterpacks'], 153 + findContactsStepEnabled: s.screens['find-contacts'], 154 + }) 145 155 break 146 156 } 147 157 case 'setInterestsStepResults': { ··· 162 172 } 163 173 } 164 174 175 + if (a.type === 'next') { 176 + if (next.activeStep === 'find-contacts-intro') { 177 + logger.metric('onboarding:contacts:presented', {}) 178 + } 179 + if (next.activeStep === 'find-contacts') { 180 + logger.metric('onboarding:contacts:begin', {}) 181 + } 182 + } 183 + 165 184 const state = { 166 185 ...next, 167 186 hasPrev: next.activeStep !== 'profile', ··· 170 189 logger.debug(`onboarding`, { 171 190 hasPrev: state.hasPrev, 172 191 activeStep: state.activeStep, 173 - activeStepIndex: state.activeStepIndex, 174 192 interestsStepResults: { 175 193 selectedInterests: state.interestsStepResults.selectedInterests, 176 194 }, ··· 183 201 184 202 return state 185 203 } 204 + 205 + function getStepOrder(s: OnboardingState): OnboardingScreen[] { 206 + return [ 207 + s.screens.profile && ('profile' as const), 208 + s.screens.interests && ('interests' as const), 209 + s.screens['suggested-accounts'] && ('suggested-accounts' as const), 210 + s.screens['suggested-starterpacks'] && ('suggested-starterpacks' as const), 211 + s.screens['find-contacts-intro'] && ('find-contacts-intro' as const), 212 + s.screens['find-contacts'] && ('find-contacts' as const), 213 + s.screens.finished && ('finished' as const), 214 + ].filter(x => !!x) 215 + } 216 + 217 + /** 218 + * Note: not to be confused with `useOnboardingState`, which just determines if onboarding is active. 219 + * This hook is for internal state of the onboarding flow (i.e. active step etc). 220 + * 221 + * This adds additional derived state to the onboarding context reducer. 222 + */ 223 + export function useOnboardingInternalState() { 224 + const ctx = useContext(Context) 225 + 226 + if (!ctx) { 227 + throw new Error( 228 + 'useOnboardingInternalState must be used within OnboardingContext', 229 + ) 230 + } 231 + 232 + const {state, dispatch} = ctx 233 + 234 + return { 235 + state: useMemo(() => { 236 + const stepOrder = getStepOrder(state).filter( 237 + x => x !== 'find-contacts' && x !== 'finished', 238 + ) as string[] 239 + const canGoBack = state.activeStep !== stepOrder[0] 240 + return { 241 + ...state, 242 + canGoBack, 243 + /** 244 + * Note: for *display* purposes only, do not lean on this 245 + * for navigation purposes! we merge certain steps! 246 + */ 247 + activeStepIndex: stepOrder.indexOf( 248 + state.activeStep === 'find-contacts' 249 + ? 'find-contacts-intro' 250 + : state.activeStep, 251 + ), 252 + totalSteps: stepOrder.length, 253 + } 254 + }, [state]), 255 + dispatch, 256 + } 257 + }
+1 -1
src/screens/PostThread/components/ThreadError.tsx
··· 7 7 import {OUTER_SPACE} from '#/screens/PostThread/const' 8 8 import {atoms as a, useTheme} from '#/alf' 9 9 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 10 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 11 11 import * as Layout from '#/components/Layout' 12 12 import {Text} from '#/components/Typography' 13 13
+1 -1
src/screens/Search/components/ModuleHeader.tsx
··· 10 10 import {Button, ButtonIcon} from '#/components/Button' 11 11 import * as FeedCard from '#/components/FeedCard' 12 12 import {sizes as iconSizes} from '#/components/icons/common' 13 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 13 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 14 14 import {Link} from '#/components/Link' 15 15 import {Text, type TextProps} from '#/components/Typography' 16 16
+562
src/screens/Settings/FindContactsSettings.tsx
··· 1 + import {useCallback, useEffect, useState} from 'react' 2 + import {type ListRenderItemInfo, View} from 'react-native' 3 + import * as Contacts from 'expo-contacts' 4 + import { 5 + type AppBskyContactDefs, 6 + type AppBskyContactGetSyncStatus, 7 + type ModerationOpts, 8 + } from '@atproto/api' 9 + import {msg, Plural, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + import {useIsFocused} from '@react-navigation/native' 12 + import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' 13 + 14 + import {wait} from '#/lib/async/wait' 15 + import {HITSLOP_10} from '#/lib/constants' 16 + import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 17 + import { 18 + type AllNavigatorParams, 19 + type NativeStackScreenProps, 20 + } from '#/lib/routes/types' 21 + import {cleanError, isNetworkError} from '#/lib/strings/errors' 22 + import {logger} from '#/logger' 23 + import {isNative} from '#/platform/detection' 24 + import { 25 + updateProfileShadow, 26 + useProfileShadow, 27 + } from '#/state/cache/profile-shadow' 28 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 29 + import { 30 + findContactsStatusQueryKey, 31 + optimisticRemoveMatch, 32 + useContactsMatchesQuery, 33 + useContactsSyncStatusQuery, 34 + } from '#/state/queries/find-contacts' 35 + import {useAgent, useSession} from '#/state/session' 36 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 37 + import {List} from '#/view/com/util/List' 38 + import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 39 + import {Admonition} from '#/components/Admonition' 40 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 41 + import {ContactsHeroImage} from '#/components/contacts/components/HeroImage' 42 + import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ResyncIcon} from '#/components/icons/ArrowRotate' 43 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 44 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 45 + import * as Layout from '#/components/Layout' 46 + import {InlineLinkText, Link} from '#/components/Link' 47 + import {Loader} from '#/components/Loader' 48 + import * as ProfileCard from '#/components/ProfileCard' 49 + import * as Toast from '#/components/Toast' 50 + import {Text} from '#/components/Typography' 51 + import type * as bsky from '#/types/bsky' 52 + import {bulkWriteFollows} from '../Onboarding/util' 53 + 54 + type Props = NativeStackScreenProps<AllNavigatorParams, 'FindContactsSettings'> 55 + export function FindContactsSettingsScreen({}: Props) { 56 + const {_} = useLingui() 57 + 58 + const {data, error, refetch} = useContactsSyncStatusQuery() 59 + 60 + const isFocused = useIsFocused() 61 + useEffect(() => { 62 + if (data && isFocused) { 63 + logger.metric('contacts:settings:presented', { 64 + hasPreviouslySynced: !!data.syncStatus, 65 + matchCount: data.syncStatus?.matchesCount, 66 + }) 67 + } 68 + }, [data, isFocused]) 69 + 70 + return ( 71 + <Layout.Screen> 72 + <Layout.Header.Outer> 73 + <Layout.Header.BackButton /> 74 + <Layout.Header.Content> 75 + <Layout.Header.TitleText> 76 + <Trans>Find Friends</Trans> 77 + </Layout.Header.TitleText> 78 + </Layout.Header.Content> 79 + <Layout.Header.Slot /> 80 + </Layout.Header.Outer> 81 + {isNative ? ( 82 + data ? ( 83 + !data.syncStatus ? ( 84 + <Intro /> 85 + ) : ( 86 + <SyncStatus info={data.syncStatus} refetchStatus={refetch} /> 87 + ) 88 + ) : error ? ( 89 + <ErrorScreen 90 + title={_(msg`Error getting the latest data.`)} 91 + message={cleanError(error)} 92 + onPressTryAgain={refetch} 93 + /> 94 + ) : ( 95 + <View style={[a.flex_1, a.justify_center, a.align_center]}> 96 + <Loader size="xl" /> 97 + </View> 98 + ) 99 + ) : ( 100 + <ErrorScreen 101 + title={_(msg`Not available on this platform.`)} 102 + message={_(msg`Please use the native app to sync your contacts.`)} 103 + /> 104 + )} 105 + </Layout.Screen> 106 + ) 107 + } 108 + 109 + function Intro() { 110 + const gutter = useGutters(['base']) 111 + const t = useTheme() 112 + const {_} = useLingui() 113 + 114 + const {data: isAvailable, isSuccess} = useQuery({ 115 + queryKey: ['contacts-available'], 116 + queryFn: async () => await Contacts.isAvailableAsync(), 117 + }) 118 + 119 + return ( 120 + <Layout.Content contentContainerStyle={[gutter, a.gap_lg]}> 121 + <ContactsHeroImage /> 122 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 123 + <Trans> 124 + Find your friends on Bluesky by verifying your phone number and 125 + matching with your contacts. We protect your information and you 126 + control what happens next.{' '} 127 + <InlineLinkText 128 + to="#" 129 + label={_( 130 + msg({ 131 + message: `Learn more about importing contacts`, 132 + context: `english-only-resource`, 133 + }), 134 + )} 135 + style={[a.text_md, a.leading_snug]}> 136 + <Trans context="english-only-resource">Learn more</Trans> 137 + </InlineLinkText> 138 + </Trans> 139 + </Text> 140 + {isAvailable ? ( 141 + <Link 142 + to={{screen: 'FindContactsFlow'}} 143 + label={_(msg`Import contacts`)} 144 + size="large" 145 + color="primary" 146 + style={[a.flex_1, a.justify_center]}> 147 + <ButtonText> 148 + <Trans>Import contacts</Trans> 149 + </ButtonText> 150 + </Link> 151 + ) : ( 152 + isSuccess && ( 153 + <Admonition type="error"> 154 + <Trans> 155 + Contact sync is not available on this device, as the app is unable 156 + to access your contacts. 157 + </Trans> 158 + </Admonition> 159 + ) 160 + )} 161 + </Layout.Content> 162 + ) 163 + } 164 + 165 + function SyncStatus({ 166 + info, 167 + refetchStatus, 168 + }: { 169 + info: AppBskyContactDefs.SyncStatus 170 + refetchStatus: () => Promise<any> 171 + }) { 172 + const agent = useAgent() 173 + const queryClient = useQueryClient() 174 + const {_} = useLingui() 175 + const moderationOpts = useModerationOpts() 176 + 177 + const { 178 + data, 179 + isPending, 180 + hasNextPage, 181 + fetchNextPage, 182 + isFetchingNextPage, 183 + refetch: refetchMatches, 184 + } = useContactsMatchesQuery() 185 + 186 + const [isPTR, setIsPTR] = useState(false) 187 + 188 + const onRefresh = () => { 189 + setIsPTR(true) 190 + Promise.all([refetchStatus(), refetchMatches()]).finally(() => { 191 + setIsPTR(false) 192 + }) 193 + } 194 + 195 + const {mutate: dismissMatch} = useMutation({ 196 + mutationFn: async (did: string) => { 197 + await agent.app.bsky.contact.dismissMatch({subject: did}) 198 + }, 199 + onMutate: async (did: string) => { 200 + logger.metric('contacts:settings:dismiss', {}) 201 + optimisticRemoveMatch(queryClient, did) 202 + }, 203 + onError: err => { 204 + refetchMatches() 205 + if (isNetworkError(err)) { 206 + Toast.show( 207 + _( 208 + msg`Could not follow all matches - please check your network connection.`, 209 + ), 210 + {type: 'error'}, 211 + ) 212 + } else { 213 + logger.error('Failed to follow all matches', {safeMessage: err}) 214 + Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), { 215 + type: 'error', 216 + }) 217 + } 218 + }, 219 + }) 220 + 221 + const profiles = data?.pages?.flatMap(page => page.matches) ?? [] 222 + 223 + const numProfiles = profiles.length 224 + const isAnyUnfollowed = profiles.some(profile => !profile.viewer?.following) 225 + 226 + const renderItem = useCallback( 227 + ({item, index}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => { 228 + if (!moderationOpts) return null 229 + return ( 230 + <MatchItem 231 + profile={item} 232 + isFirst={index === 0} 233 + isLast={index === numProfiles - 1} 234 + moderationOpts={moderationOpts} 235 + dismissMatch={dismissMatch} 236 + /> 237 + ) 238 + }, 239 + [numProfiles, moderationOpts, dismissMatch], 240 + ) 241 + 242 + const onEndReached = () => { 243 + if (!hasNextPage || isFetchingNextPage) return 244 + fetchNextPage() 245 + } 246 + 247 + return ( 248 + <List 249 + data={profiles} 250 + renderItem={renderItem} 251 + ListHeaderComponent={ 252 + <StatusHeader 253 + numMatches={info.matchesCount} 254 + isPending={isPending} 255 + isAnyUnfollowed={isAnyUnfollowed} 256 + /> 257 + } 258 + ListFooterComponent={<StatusFooter syncedAt={info.syncedAt} />} 259 + onRefresh={onRefresh} 260 + refreshing={isPTR} 261 + onEndReached={onEndReached} 262 + /> 263 + ) 264 + } 265 + 266 + function MatchItem({ 267 + profile, 268 + isFirst, 269 + isLast, 270 + moderationOpts, 271 + dismissMatch, 272 + }: { 273 + profile: bsky.profile.AnyProfileView 274 + isFirst: boolean 275 + isLast: boolean 276 + moderationOpts: ModerationOpts 277 + dismissMatch: (did: string) => void 278 + }) { 279 + const t = useTheme() 280 + const {_} = useLingui() 281 + const shadow = useProfileShadow(profile) 282 + 283 + return ( 284 + <View style={[a.px_xl]}> 285 + <View 286 + style={[ 287 + a.p_md, 288 + a.border_t, 289 + a.border_x, 290 + t.atoms.border_contrast_high, 291 + isFirst && [ 292 + a.curve_continuous, 293 + {borderTopLeftRadius: tokens.borderRadius.lg}, 294 + {borderTopRightRadius: tokens.borderRadius.lg}, 295 + ], 296 + isLast && [ 297 + a.border_b, 298 + a.curve_continuous, 299 + {borderBottomLeftRadius: tokens.borderRadius.lg}, 300 + {borderBottomRightRadius: tokens.borderRadius.lg}, 301 + a.mb_sm, 302 + ], 303 + ]}> 304 + <ProfileCard.Header> 305 + <ProfileCard.Avatar 306 + profile={profile} 307 + moderationOpts={moderationOpts} 308 + /> 309 + <ProfileCard.NameAndHandle 310 + profile={profile} 311 + moderationOpts={moderationOpts} 312 + /> 313 + <ProfileCard.FollowButton 314 + profile={profile} 315 + moderationOpts={moderationOpts} 316 + logContext="FindContacts" 317 + onFollow={() => logger.metric('contacts:settings:follow', {})} 318 + /> 319 + {!shadow.viewer?.following && ( 320 + <Button 321 + color="secondary" 322 + variant="ghost" 323 + label={_(msg`Remove suggestion`)} 324 + onPress={() => dismissMatch(profile.did)} 325 + hoverStyle={[a.bg_transparent, {opacity: 0.5}]} 326 + hitSlop={8}> 327 + <ButtonIcon icon={XIcon} /> 328 + </Button> 329 + )} 330 + </ProfileCard.Header> 331 + </View> 332 + </View> 333 + ) 334 + } 335 + 336 + function StatusHeader({ 337 + numMatches, 338 + isPending, 339 + isAnyUnfollowed, 340 + }: { 341 + numMatches: number 342 + isPending: boolean 343 + isAnyUnfollowed: boolean 344 + }) { 345 + const {_} = useLingui() 346 + const agent = useAgent() 347 + const queryClient = useQueryClient() 348 + const {currentAccount} = useSession() 349 + 350 + const { 351 + mutate: onFollowAll, 352 + isPending: isFollowingAll, 353 + isSuccess: hasFollowedAll, 354 + } = useMutation({ 355 + mutationFn: async () => { 356 + const didsToFollow = [] 357 + 358 + let cursor: string | undefined 359 + do { 360 + const page = await agent.app.bsky.contact.getMatches({ 361 + limit: 100, 362 + cursor, 363 + }) 364 + cursor = page.data.cursor 365 + for (const profile of page.data.matches) { 366 + if ( 367 + profile.did !== currentAccount?.did && 368 + !isBlockedOrBlocking(profile) && 369 + !isMuted(profile) && 370 + !profile.viewer?.following 371 + ) { 372 + didsToFollow.push(profile.did) 373 + } 374 + } 375 + } while (cursor) 376 + 377 + logger.metric('contacts:settings:followAll', { 378 + followCount: didsToFollow.length, 379 + }) 380 + 381 + const uris = await wait(500, bulkWriteFollows(agent, didsToFollow)) 382 + 383 + for (const did of didsToFollow) { 384 + const uri = uris.get(did) 385 + updateProfileShadow(queryClient, did, { 386 + followingUri: uri, 387 + }) 388 + } 389 + }, 390 + onSuccess: () => { 391 + Toast.show(_(msg`Followed all matches`), {type: 'success'}) 392 + }, 393 + onError: err => { 394 + if (isNetworkError(err)) { 395 + Toast.show( 396 + _( 397 + msg`Could not follow all matches - please check your network connection.`, 398 + ), 399 + {type: 'error'}, 400 + ) 401 + } else { 402 + logger.error('Failed to follow all matches', {safeMessage: err}) 403 + Toast.show(_(msg`Could not follow all matches. ${cleanError(err)}`), { 404 + type: 'error', 405 + }) 406 + } 407 + }, 408 + }) 409 + 410 + if (numMatches > 0) { 411 + if (isPending) { 412 + return ( 413 + <View style={[a.w_full, a.py_3xl, a.align_center]}> 414 + <Loader size="xl" /> 415 + </View> 416 + ) 417 + } 418 + 419 + return ( 420 + <View 421 + style={[ 422 + a.pt_xl, 423 + a.px_xl, 424 + a.pb_md, 425 + a.flex_row, 426 + a.justify_between, 427 + a.align_center, 428 + ]}> 429 + <Text style={[a.text_md, a.font_semi_bold]}> 430 + <Plural 431 + value={numMatches} 432 + one="1 contact found" 433 + other="# contacts found" 434 + /> 435 + </Text> 436 + {isAnyUnfollowed && ( 437 + <Button 438 + label={_(msg`Follow all`)} 439 + color="primary" 440 + size="small" 441 + variant="ghost" 442 + onPress={() => onFollowAll()} 443 + disabled={isFollowingAll || hasFollowedAll} 444 + hitSlop={HITSLOP_10} 445 + style={[a.px_0, a.py_0, a.rounded_0]} 446 + hoverStyle={[a.bg_transparent, {opacity: 0.5}]}> 447 + <ButtonText> 448 + <Trans>Follow all</Trans> 449 + </ButtonText> 450 + </Button> 451 + )} 452 + </View> 453 + ) 454 + } 455 + 456 + return null 457 + } 458 + 459 + function StatusFooter({syncedAt}: {syncedAt: string}) { 460 + const {_, i18n} = useLingui() 461 + const t = useTheme() 462 + const agent = useAgent() 463 + const queryClient = useQueryClient() 464 + 465 + const {mutate: removeData, isPending} = useMutation({ 466 + mutationFn: async () => { 467 + await agent.app.bsky.contact.removeData({}) 468 + }, 469 + onMutate: () => logger.metric('contacts:settings:removeData', {}), 470 + onSuccess: () => { 471 + Toast.show(_(msg`Contacts removed`)) 472 + queryClient.setQueryData<AppBskyContactGetSyncStatus.OutputSchema>( 473 + findContactsStatusQueryKey, 474 + {syncStatus: undefined}, 475 + ) 476 + }, 477 + onError: err => { 478 + if (isNetworkError(err)) { 479 + Toast.show( 480 + _( 481 + msg`Failed to remove data due to a network error, please check your internet connection.`, 482 + ), 483 + {type: 'error'}, 484 + ) 485 + } else { 486 + logger.error('Remove data failed', {safeMessage: err}) 487 + Toast.show(_(msg`Failed to remove data. ${cleanError(err)}`), { 488 + type: 'error', 489 + }) 490 + } 491 + }, 492 + }) 493 + 494 + return ( 495 + <View style={[a.px_xl, a.py_xl, a.gap_4xl]}> 496 + <View style={[a.gap_xs, a.align_start]}> 497 + <Text style={[a.text_md, a.font_semi_bold]}> 498 + <Trans>Contacts uploaded</Trans> 499 + </Text> 500 + <View style={[a.gap_2xs]}> 501 + <Text 502 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 503 + <Trans>We will notify you when we find your friends.</Trans> 504 + </Text> 505 + <Text 506 + style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 507 + <Trans> 508 + Uploaded on{' '} 509 + {i18n.date(new Date(syncedAt), { 510 + dateStyle: 'long', 511 + })} 512 + </Trans> 513 + </Text> 514 + </View> 515 + <Link 516 + label={_(msg`Resync contacts`)} 517 + to={{screen: 'FindContactsFlow'}} 518 + onPress={() => { 519 + const daysSinceLastSync = Math.floor( 520 + (Date.now() - new Date(syncedAt).getTime()) / 521 + (1000 * 60 * 60 * 24), 522 + ) 523 + logger.metric('contacts:settings:resync', { 524 + daysSinceLastSync, 525 + }) 526 + }} 527 + size="small" 528 + color="primary_subtle" 529 + style={[a.mt_xs]}> 530 + <ButtonIcon icon={ResyncIcon} /> 531 + <ButtonText> 532 + <Trans>Resync contacts</Trans> 533 + </ButtonText> 534 + </Link> 535 + </View> 536 + 537 + <View style={[a.gap_xs, a.align_start]}> 538 + <Text style={[a.text_md, a.font_semi_bold]}> 539 + <Trans>Delete contacts</Trans> 540 + </Text> 541 + <Text style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 542 + <Trans> 543 + Bluesky stores your contacts as encoded data. Removing your contacts 544 + will immediately delete this data. 545 + </Trans> 546 + </Text> 547 + <Button 548 + label={_(msg`Remove all contacts`)} 549 + onPress={() => removeData()} 550 + size="small" 551 + color="negative_subtle" 552 + disabled={isPending} 553 + style={[a.mt_xs]}> 554 + <ButtonIcon icon={isPending ? Loader : TrashIcon} /> 555 + <ButtonText> 556 + <Trans>Remove all contacts</Trans> 557 + </ButtonText> 558 + </Button> 559 + </View> 560 + </View> 561 + ) 562 + }
+14
src/screens/Settings/Settings.tsx
··· 37 37 import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice' 38 38 import {AvatarStackWithFetch} from '#/components/AvatarStack' 39 39 import {Button, ButtonText} from '#/components/Button' 40 + import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 40 41 import {useDialogControl} from '#/components/Dialog' 41 42 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 42 43 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' ··· 45 46 import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' 46 47 import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' 47 48 import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 49 + import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts' 48 50 import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' 49 51 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' 50 52 import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' ··· 89 91 const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() 90 92 const [showAccounts, setShowAccounts] = useState(false) 91 93 const [showDevOptions, setShowDevOptions] = useState(false) 94 + const findContactsEnabled = 95 + useIsFindContactsFeatureEnabledBasedOnGeolocation() 92 96 93 97 return ( 94 98 <Layout.Screen> ··· 207 211 <Trans>Content and media</Trans> 208 212 </SettingsList.ItemText> 209 213 </SettingsList.LinkItem> 214 + {isNative && findContactsEnabled && ( 215 + <SettingsList.LinkItem 216 + to="/settings/find-contacts" 217 + label={_(msg`Find friends from contacts`)}> 218 + <SettingsList.ItemIcon icon={ContactsIcon} /> 219 + <SettingsList.ItemText> 220 + <Trans>Find friends from contacts</Trans> 221 + </SettingsList.ItemText> 222 + </SettingsList.LinkItem> 223 + )} 210 224 <SettingsList.LinkItem 211 225 to="/settings/appearance" 212 226 label={_(msg`Appearance`)}>
+1 -1
src/screens/Settings/components/OTAInfo.tsx
··· 5 5 6 6 import * as Toast from '#/view/com/util/Toast' 7 7 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 8 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise' 8 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' 9 9 import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' 10 10 import {Loader} from '#/components/Loader' 11 11 import * as SettingsList from '../components/SettingsList'
+2
src/state/cache/profile-shadow.ts
··· 7 7 import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions' 8 8 import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search' 9 9 import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' 10 + import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData} from '#/state/queries/find-contacts' 10 11 import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' 11 12 import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members' 12 13 import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations' ··· 256 257 yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) 257 258 yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did) 258 259 yield* findAllProfilesInNotifsQueryData(queryClient, did) 260 + yield* findAllProfilesInContactMatchesQueryData(queryClient, did) 259 261 }
+128
src/state/queries/find-contacts.ts
··· 1 + import {type AppBskyContactGetMatches} from '@atproto/api' 2 + import { 3 + type InfiniteData, 4 + type QueryClient, 5 + useInfiniteQuery, 6 + useQuery, 7 + } from '@tanstack/react-query' 8 + 9 + import {useAgent} from '#/state/session' 10 + import {type Match} from '#/components/contacts/state' 11 + import type * as bsky from '#/types/bsky' 12 + import {STALE} from '.' 13 + 14 + const RQ_KEY_ROOT = 'find-contacts' 15 + export const findContactsStatusQueryKey = [RQ_KEY_ROOT, 'sync-status'] 16 + 17 + export function useContactsSyncStatusQuery() { 18 + const agent = useAgent() 19 + 20 + return useQuery({ 21 + queryKey: findContactsStatusQueryKey, 22 + queryFn: async () => { 23 + const status = await agent.app.bsky.contact.getSyncStatus() 24 + return status.data 25 + }, 26 + staleTime: STALE.SECONDS.THIRTY, 27 + }) 28 + } 29 + 30 + export const findContactsGetMatchesQueryKey = [RQ_KEY_ROOT, 'matches'] 31 + 32 + export function useContactsMatchesQuery() { 33 + const agent = useAgent() 34 + 35 + return useInfiniteQuery({ 36 + queryKey: findContactsGetMatchesQueryKey, 37 + queryFn: async ({pageParam}) => { 38 + const matches = await agent.app.bsky.contact.getMatches({ 39 + cursor: pageParam, 40 + }) 41 + return matches.data 42 + }, 43 + initialPageParam: undefined as string | undefined, 44 + getNextPageParam: lastPage => lastPage.cursor, 45 + staleTime: STALE.MINUTES.ONE, 46 + }) 47 + } 48 + 49 + export function optimisticRemoveMatch(queryClient: QueryClient, did: string) { 50 + queryClient.setQueryData<InfiniteData<AppBskyContactGetMatches.OutputSchema>>( 51 + findContactsGetMatchesQueryKey, 52 + old => { 53 + if (!old) return old 54 + 55 + return { 56 + ...old, 57 + pages: old.pages.map(page => ({ 58 + ...page, 59 + matches: page.matches.filter(match => match.did !== did), 60 + })), 61 + } 62 + }, 63 + ) 64 + } 65 + 66 + export const findContactsMatchesPassthroughQueryKey = (dids: string[]) => [ 67 + RQ_KEY_ROOT, 68 + 'passthrough', 69 + dids, 70 + ] 71 + 72 + /** 73 + * DIRTY HACK WARNING! 74 + * 75 + * The only way to get shadow state to work is to put it into React Query. 76 + * However, when we get the matches it's via a POST, not a GET, so we use a mutation, 77 + * which means we can't use shadowing! 78 + * 79 + * In lieu of any better ideas, I'm just going to take the contacts we have and 80 + * "launder" them through a dummy query. This will then return "shadow-able" profiles. 81 + */ 82 + export function useMatchesPassthroughQuery(matches: Match[]) { 83 + const dids = matches.map(match => match.profile.did) 84 + const {data} = useQuery({ 85 + queryKey: findContactsMatchesPassthroughQueryKey(dids), 86 + queryFn: () => { 87 + return matches 88 + }, 89 + }) 90 + return data ?? matches 91 + } 92 + 93 + export function* findAllProfilesInQueryData( 94 + queryClient: QueryClient, 95 + did: string, 96 + ): Generator<bsky.profile.AnyProfileView, void> { 97 + const queryDatas = queryClient.getQueriesData< 98 + InfiniteData<AppBskyContactGetMatches.OutputSchema> 99 + >({ 100 + queryKey: findContactsGetMatchesQueryKey, 101 + }) 102 + for (const [_queryKey, queryData] of queryDatas) { 103 + if (!queryData?.pages) { 104 + continue 105 + } 106 + for (const page of queryData?.pages) { 107 + for (const match of page.matches) { 108 + if (match.did === did) { 109 + yield match 110 + } 111 + } 112 + } 113 + } 114 + 115 + const passthroughQueryDatas = queryClient.getQueriesData<Match[]>({ 116 + queryKey: [RQ_KEY_ROOT, 'passthrough'], 117 + }) 118 + for (const [_queryKey, queryData] of passthroughQueryDatas) { 119 + if (!queryData) { 120 + continue 121 + } 122 + for (const match of queryData) { 123 + if (match.profile.did === did) { 124 + yield match.profile 125 + } 126 + } 127 + } 128 + }
+4 -1
src/state/queries/notifications/feed.ts
··· 319 319 } 320 320 for (const page of queryData?.pages) { 321 321 for (const item of page.items) { 322 - if (item.type === 'follow' && item.notification.author.did === did) { 322 + if ( 323 + (item.type === 'follow' || item.type === 'contact-match') && 324 + item.notification.author.did === did 325 + ) { 323 326 yield item.notification.author 324 327 } else if ( 325 328 item.type !== 'starterpack-joined' &&
+1
src/state/queries/notifications/types.ts
··· 49 49 | 'like-via-repost' 50 50 | 'repost-via-repost' 51 51 | 'subscribed-post' 52 + | 'contact-match' 52 53 | 'unknown' 53 54 54 55 type FeedNotificationBase = {
+2 -1
src/state/queries/notifications/util.ts
··· 273 273 notif.reason === 'unverified' || 274 274 notif.reason === 'like-via-repost' || 275 275 notif.reason === 'repost-via-repost' || 276 - notif.reason === 'subscribed-post' 276 + notif.reason === 'subscribed-post' || 277 + notif.reason === 'contact-match' 277 278 ) { 278 279 return notif.reason as NotificationType 279 280 }
+12
src/state/queries/nuxs/definitions.ts
··· 10 10 AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 11 AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', 12 12 BookmarksAnnouncement = 'BookmarksAnnouncement', 13 + FindContactsAnnouncement = 'FindContactsAnnouncement', 14 + FindContactsDismissibleBanner = 'FindContactsDismissibleBanner', 13 15 14 16 /* 15 17 * Blocking announcements. New IDs are required for each new announcement. ··· 52 54 id: Nux.BookmarksAnnouncement 53 55 data: undefined 54 56 } 57 + | { 58 + id: Nux.FindContactsAnnouncement 59 + data: undefined 60 + } 61 + | { 62 + id: Nux.FindContactsDismissibleBanner 63 + data: undefined 64 + } 55 65 > 56 66 57 67 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 63 73 [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, 64 74 [Nux.PolicyUpdate202508]: undefined, 65 75 [Nux.BookmarksAnnouncement]: undefined, 76 + [Nux.FindContactsAnnouncement]: undefined, 77 + [Nux.FindContactsDismissibleBanner]: undefined, 66 78 }
+20 -14
src/view/com/notifications/NotificationFeedItem.tsx
··· 1 - import { 2 - memo, 3 - type ReactElement, 4 - useCallback, 5 - useEffect, 6 - useMemo, 7 - useState, 8 - } from 'react' 1 + import {memo, useCallback, useEffect, useMemo, useState} from 'react' 9 2 import { 10 3 Animated, 11 4 type GestureResponderEvent, ··· 61 54 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 62 55 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 63 56 } from '#/components/icons/Chevron' 57 + import {Contacts_Filled_Corner2_Rounded as ContactsIconFilled} from '#/components/icons/Contacts' 64 58 import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2' 65 59 import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' 66 60 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' ··· 116 110 break 117 111 } 118 112 case 'follow': 113 + case 'contact-match': 119 114 case 'verified': 120 115 case 'unverified': { 121 116 return makeProfileLink(item.notification.author) ··· 279 274 : '' 280 275 281 276 let a11yLabel = '' 282 - let notificationContent: ReactElement<any> 277 + let notificationContent: React.ReactElement<any> 283 278 let icon = ( 284 279 <HeartIconFilled 285 280 size="xl" ··· 373 368 ) 374 369 } 375 370 icon = <PersonPlusIcon size="xl" style={{color: t.palette.primary_500}} /> 371 + } else if (item.type === 'contact-match') { 372 + a11yLabel = _(msg`Your contact ${firstAuthorName} is on Bluesky`) 373 + notificationContent = ( 374 + <Trans>Your contact {firstAuthorLink} is on Bluesky</Trans> 375 + ) 376 + icon = ( 377 + <ContactsIconFilled size="xl" style={{color: t.palette.primary_500}} /> 378 + ) 376 379 } else if (item.type === 'feedgen-like') { 377 380 a11yLabel = hasMultipleAuthors 378 381 ? _( ··· 438 441 notificationContent = hasMultipleAuthors ? ( 439 442 <Trans> 440 443 {firstAuthorLink} and{' '} 441 - <Text style={[pal.text, s.bold]}> 444 + <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 442 445 <Plural 443 446 value={additionalAuthorsCount} 444 447 one={`${formattedAuthorsCount} other`} ··· 463 466 notificationContent = hasMultipleAuthors ? ( 464 467 <Trans> 465 468 {firstAuthorLink} and{' '} 466 - <Text style={[pal.text, s.bold]}> 469 + <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 467 470 <Plural 468 471 value={additionalAuthorsCount} 469 472 one={`${formattedAuthorsCount} other`} ··· 670 673 </TimeElapsed> 671 674 </Text> 672 675 </ExpandListPressable> 673 - {item.type === 'follow' && !hasMultipleAuthors && !isFollowBack ? ( 676 + {(item.type === 'follow' && !hasMultipleAuthors && !isFollowBack) || 677 + (item.type === 'contact-match' && 678 + !item.notification.author.viewer?.following) ? ( 674 679 <FollowBackButton profile={item.notification.author} /> 675 680 ) : null} 676 681 {item.type === 'post-like' || ··· 809 814 } 810 815 811 816 const isFollowing = profileShadow.viewer.following 817 + const isFollowedBy = profileShadow.viewer.followedBy 812 818 const followingLabel = _( 813 819 msg({ 814 820 message: 'Following', ··· 832 838 </Button> 833 839 ) : ( 834 840 <Button 835 - label={_(msg`Follow back`)} 841 + label={isFollowedBy ? _(msg`Follow back`) : _(msg`Follow`)} 836 842 color="primary" 837 843 size="small" 838 844 style={[a.self_start]} 839 845 onPress={onPressFollow}> 840 846 <ButtonIcon icon={PlusIcon} /> 841 847 <ButtonText> 842 - <Trans>Follow back</Trans> 848 + {isFollowedBy ? <Trans>Follow back</Trans> : <Trans>Follow</Trans>} 843 849 </ButtonText> 844 850 </Button> 845 851 )}
+2
src/view/com/profile/ProfileFollows.tsx
··· 12 12 import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 13 13 import {useResolveDidQuery} from '#/state/queries/resolve-uri' 14 14 import {useSession} from '#/state/session' 15 + import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX' 15 16 import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2' 16 17 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 17 18 import {List} from '../util/List' ··· 208 209 onEndReached={onEndReached} 209 210 onEndReachedThreshold={4} 210 211 onItemSeen={onItemSeen} 212 + ListHeaderComponent={<FindContactsBannerNUX />} 211 213 ListFooterComponent={ 212 214 <ListFooter 213 215 isFetchingNextPage={isFetchingNextPage}
+1 -1
src/view/com/profile/ProfileMenu.tsx
··· 37 37 import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 38 38 import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 39 39 import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' 40 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 40 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' 41 41 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 42 42 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 43 43 import {
+1 -1
src/view/com/util/error/ErrorScreen.tsx
··· 9 9 import {usePalette} from '#/lib/hooks/usePalette' 10 10 import {atoms as a, useTheme} from '#/alf' 11 11 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' 12 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate' 13 13 import * as Layout from '#/components/Layout' 14 14 import {Text} from '#/components/Typography' 15 15
+1 -1
src/view/screens/Storybook/Admonitions.tsx
··· 13 13 Text as AdmonitionText, 14 14 } from '#/components/Admonition' 15 15 import {ButtonIcon, ButtonText} from '#/components/Button' 16 - import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' 16 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' 17 17 import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging' 18 18 import {InlineLinkText} from '#/components/Link' 19 19 import {H1} from '#/components/Typography'
+1 -1
src/view/screens/Storybook/Menus.tsx
··· 1 1 import {View} from 'react-native' 2 2 3 3 import {atoms as a, useTheme} from '#/alf' 4 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 4 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 5 5 import * as Menu from '#/components/Menu' 6 6 import {Text} from '#/components/Typography' 7 7 // import {useDialogStateControlContext} from '#/state/dialogs'
+1 -1
src/view/shell/Drawer.tsx
··· 41 41 HomeOpen_Stoke2_Corner0_Rounded as Home, 42 42 } from '#/components/icons/HomeOpen' 43 43 import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 44 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 44 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 45 45 import { 46 46 Message_Stroke2_Corner0_Rounded as Message, 47 47 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
src/view/shell/bottom-bar/BottomBar.tsx
··· 44 44 HomeOpen_Stoke2_Corner0_Rounded as Home, 45 45 } from '#/components/icons/HomeOpen' 46 46 import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 47 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 47 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 48 48 import { 49 49 Message_Stroke2_Corner0_Rounded as Message, 50 50 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 32 32 HomeOpen_Stoke2_Corner0_Rounded as Home, 33 33 } from '#/components/icons/HomeOpen' 34 34 import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 35 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 35 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 36 36 import { 37 37 Message_Stroke2_Corner0_Rounded as Message, 38 38 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 56 56 HomeOpen_Stoke2_Corner0_Rounded as Home, 57 57 } from '#/components/icons/HomeOpen' 58 58 import {MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled} from '#/components/icons/MagnifyingGlass' 59 - import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 59 + import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass' 60 60 import { 61 61 Message_Stroke2_Corner0_Rounded as Message, 62 62 Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
+480 -502
yarn.lock
··· 82 82 "@atproto/xrpc" "^0.7.6" 83 83 "@atproto/xrpc-server" "^0.10.0" 84 84 85 - "@atproto/api@^0.18.4": 86 - version "0.18.4" 87 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.4.tgz#e6742f3b81acec2bcf63dd3787304166eb2891cb" 88 - integrity sha512-+kSxto/GRFXRFFlGwfERrwEKnC6OqTgK34BUToer/Fv08q4WMR+GYPRabbWlnDoJWu3owcQfeYdcblQ88vi16g== 85 + "@atproto/api@^0.18.5", "@atproto/api@^0.18.7": 86 + version "0.18.7" 87 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.7.tgz#3175ec8f1909ddcae488183a2180de234e7acce4" 88 + integrity sha512-vUluqN1XU5AX5tgfSJjjjUzALCMq8DjdI0jlIhRYyn2Chb0ZOCU8k0ZTpUAcuDFE2FoxxW4S3kvtlHwLMtN5dQ== 89 89 dependencies: 90 - "@atproto/common-web" "^0.4.6" 91 - "@atproto/lexicon" "^0.5.2" 90 + "@atproto/common-web" "^0.4.7" 91 + "@atproto/lexicon" "^0.6.0" 92 92 "@atproto/syntax" "^0.4.2" 93 - "@atproto/xrpc" "^0.7.6" 93 + "@atproto/xrpc" "^0.7.7" 94 94 await-lock "^2.2.2" 95 95 multiformats "^9.9.0" 96 96 tlds "^1.234.0" ··· 110 110 tlds "^1.234.0" 111 111 zod "^3.23.8" 112 112 113 + "@atproto/api@^0.18.8": 114 + version "0.18.8" 115 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.18.8.tgz#6df69731005413d8507345829a12abeda787c32d" 116 + integrity sha512-Qo3sGd1N5hdHTaEWUBgptvPkULt2SXnMcWRhveSyctSd/IQwTMyaIH6E62A1SU+8xBSN5QLpoUJNE7iSrYM2Zg== 117 + dependencies: 118 + "@atproto/common-web" "^0.4.7" 119 + "@atproto/lexicon" "^0.6.0" 120 + "@atproto/syntax" "^0.4.2" 121 + "@atproto/xrpc" "^0.7.7" 122 + await-lock "^2.2.2" 123 + multiformats "^9.9.0" 124 + tlds "^1.234.0" 125 + zod "^3.23.8" 126 + 113 127 "@atproto/aws@^0.2.31": 114 128 version "0.2.31" 115 129 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.31.tgz#e46d7db34ee57c4f9817269f1e73a7eddba2b9b8" ··· 128 142 multiformats "^9.9.0" 129 143 uint8arrays "3.0.0" 130 144 131 - "@atproto/bsky@^0.0.199": 132 - version "0.0.199" 133 - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.199.tgz#44ee12e0192b0f946745317bed474525f45e02c9" 134 - integrity sha512-DtW1G5k8dSBP5rs3fid0RRV+5efsWe2WG0AB1MxhnhU8718CnpGsPX8sx6uZtMvaTKgDynfN7fPNtaxNomVGFA== 145 + "@atproto/bsky@^0.0.202": 146 + version "0.0.202" 147 + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.202.tgz#7b4376dba273a4c1b810846074974d1b11a58e5c" 148 + integrity sha512-QE0T/Vr/WdPZ1lUAZNtNqkH9D+Ii7+95oUR0Q2EYyr0J9G3hlLTvc/FJog91aSe6fzZludRB/n1sRUfdMRePdg== 135 149 dependencies: 136 150 "@atproto-labs/fetch-node" "0.2.0" 137 151 "@atproto-labs/xrpc-utils" "0.0.24" 138 - "@atproto/api" "^0.18.4" 139 - "@atproto/common" "^0.5.2" 152 + "@atproto/api" "^0.18.7" 153 + "@atproto/common" "^0.5.3" 140 154 "@atproto/crypto" "^0.4.5" 141 155 "@atproto/did" "^0.2.3" 142 156 "@atproto/identity" "^0.4.10" 143 - "@atproto/lexicon" "^0.5.2" 144 - "@atproto/repo" "^0.8.11" 145 - "@atproto/sync" "^0.1.38" 157 + "@atproto/lexicon" "^0.6.0" 158 + "@atproto/repo" "^0.8.12" 159 + "@atproto/sync" "^0.1.39" 146 160 "@atproto/syntax" "^0.4.2" 147 - "@atproto/xrpc-server" "^0.10.2" 161 + "@atproto/xrpc-server" "^0.10.3" 148 162 "@bufbuild/protobuf" "^1.5.0" 149 163 "@connectrpc/connect" "^1.1.4" 150 164 "@connectrpc/connect-express" "^1.1.4" ··· 244 258 multiformats "^9.9.0" 245 259 pino "^8.21.0" 246 260 261 + "@atproto/common@^0.5.3": 262 + version "0.5.3" 263 + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.5.3.tgz#256883425c854a21fc9946b1501cbd0bb4c49d69" 264 + integrity sha512-jMC9ikl8QbJcnh21upe9Gb9mIaSJWsdp8sgaelmntUtChWnxxvCC/pI3TBX11PT7XlHUE6UyuvY+S3hh6WZVEg== 265 + dependencies: 266 + "@atproto/common-web" "^0.4.7" 267 + "@atproto/lex-cbor" "0.0.3" 268 + "@atproto/lex-data" "0.0.3" 269 + iso-datestring-validator "^2.2.2" 270 + multiformats "^9.9.0" 271 + pino "^8.21.0" 272 + 247 273 "@atproto/crypto@0.1.0": 248 274 version "0.1.0" 249 275 resolved "https://registry.yarnpkg.com/@atproto/crypto/-/crypto-0.1.0.tgz#bc73a479f9dbe06fa025301c182d7f7ab01bc568" ··· 273 299 "@noble/hashes" "^1.6.1" 274 300 uint8arrays "3.0.0" 275 301 276 - "@atproto/dev-env@^0.3.193": 277 - version "0.3.193" 278 - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.193.tgz#198333a46d3f337f36fe30c7456c6287cebc8393" 279 - integrity sha512-QPftK8FbVLGrj9IhDnPP7JCIIweYNXZ/zeaZAjolZxpIaCouPHYXXgRMZqrYxN8+RmO0s6T9czm5+xaFuXjkkw== 302 + "@atproto/dev-env@^0.3.196": 303 + version "0.3.196" 304 + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.196.tgz#e98d3f3e7a7cee874425b01bfc164e22d1111664" 305 + integrity sha512-65LrDGcGIFrZ+JnQlW38ZFO9jYO1Sp9fHc2rRq6bZzfAGT6c7K3EWHhXhNDtQ4oL3DYAT9+p56Pcv68XMaSipA== 280 306 dependencies: 281 - "@atproto/api" "^0.18.4" 282 - "@atproto/bsky" "^0.0.199" 307 + "@atproto/api" "^0.18.7" 308 + "@atproto/bsky" "^0.0.202" 283 309 "@atproto/bsync" "^0.0.23" 284 - "@atproto/common-web" "^0.4.6" 310 + "@atproto/common-web" "^0.4.7" 285 311 "@atproto/crypto" "^0.4.5" 286 312 "@atproto/identity" "^0.4.10" 287 - "@atproto/lexicon" "^0.5.2" 288 - "@atproto/ozone" "^0.1.159" 289 - "@atproto/pds" "^0.4.197" 290 - "@atproto/sync" "^0.1.38" 313 + "@atproto/lexicon" "^0.6.0" 314 + "@atproto/ozone" "^0.1.160" 315 + "@atproto/pds" "^0.4.199" 316 + "@atproto/sync" "^0.1.39" 291 317 "@atproto/syntax" "^0.4.2" 292 - "@atproto/xrpc-server" "^0.10.2" 318 + "@atproto/xrpc-server" "^0.10.3" 293 319 "@did-plc/lib" "^0.0.1" 294 320 "@did-plc/server" "^0.0.1" 295 321 dotenv "^16.0.3" ··· 330 356 multiformats "^9.9.0" 331 357 zod "^3.23.8" 332 358 333 - "@atproto/lex-cbor@0.0.2", "@atproto/lex-cbor@^0.0.2": 359 + "@atproto/lex-cbor@0.0.2": 334 360 version "0.0.2" 335 361 resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.2.tgz#b05035940407f64dfad80289855776814ff85314" 336 362 integrity sha512-sTr3UCL2SgxEoYVpzJGgWTnNl4TpngP5tMcRyaOvi21Se4m3oR4RDsoVDPz8AS6XphiteRwzwPstquN7aWWMbA== ··· 339 365 multiformats "^9.9.0" 340 366 tslib "^2.8.1" 341 367 342 - "@atproto/lex-client@0.0.3": 368 + "@atproto/lex-cbor@0.0.3", "@atproto/lex-cbor@^0.0.3": 343 369 version "0.0.3" 344 - resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.3.tgz#07079d42fe7b09fa44dde160f72579bed7aee486" 345 - integrity sha512-EvS6tmRA5jJwsWleVpxRYpbNpfm9a9VT2A/muFdPuvUuYRPzVKm2cKCperwEnQmT7HuTA7p35dIg/0if75V0Qw== 370 + resolved "https://registry.yarnpkg.com/@atproto/lex-cbor/-/lex-cbor-0.0.3.tgz#13712fa5216cd336ebbd31fbd34d35bf6ea21492" 371 + integrity sha512-N8lCV3kK5ZcjSOWxKLWqzlnaSpK4isjXRZ0EqApl/5y9KB64s78hQ/U3KIE5qnPRlBbW5kSH3YACoU27u9nTOA== 346 372 dependencies: 347 - "@atproto/lex-data" "0.0.2" 348 - "@atproto/lex-json" "0.0.2" 349 - "@atproto/lex-schema" "0.0.3" 373 + "@atproto/lex-data" "0.0.3" 374 + multiformats "^9.9.0" 350 375 tslib "^2.8.1" 351 376 352 - "@atproto/lex-data@0.0.2", "@atproto/lex-data@^0.0.2": 377 + "@atproto/lex-client@0.0.4": 378 + version "0.0.4" 379 + resolved "https://registry.yarnpkg.com/@atproto/lex-client/-/lex-client-0.0.4.tgz#04fca87296f3177ba122e41bed89d36b4d81b01a" 380 + integrity sha512-tGaenywYo6IvzKKMYuZB+sMBQNDkQ53PLz/NM0WeXnaa/e58VhOGzwVLnNSI/QR9qLlwHFfMf8AIFZyAVHBWCw== 381 + dependencies: 382 + "@atproto/lex-data" "0.0.3" 383 + "@atproto/lex-json" "0.0.3" 384 + "@atproto/lex-schema" "0.0.4" 385 + tslib "^2.8.1" 386 + 387 + "@atproto/lex-data@0.0.2": 353 388 version "0.0.2" 354 389 resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.2.tgz#f90e7ac52dd6056199a84efc7a3c5196de7ceb63" 355 390 integrity sha512-euV2rDGi+coH8qvZOU+ieUOEbwPwff9ca6IiXIqjZJ76AvlIpj7vtAyIRCxHUW2BoU6h9yqyJgn9MKD2a7oIwg== ··· 360 395 uint8arrays "3.0.0" 361 396 unicode-segmenter "^0.14.0" 362 397 363 - "@atproto/lex-data@0.0.3": 398 + "@atproto/lex-data@0.0.3", "@atproto/lex-data@^0.0.3": 364 399 version "0.0.3" 365 400 resolved "https://registry.yarnpkg.com/@atproto/lex-data/-/lex-data-0.0.3.tgz#1ce1bd19e17af41b2991a2a02ad439a136dec4e9" 366 401 integrity sha512-ivo1IpY/EX+RIpxPgCf4cPhQo5bfu4nrpa1vJCt8hCm9SfoonJkDFGa0n4SMw4JnXZoUcGcrJ46L+D8bH6GI2g== ··· 371 406 uint8arrays "3.0.0" 372 407 unicode-segmenter "^0.14.0" 373 408 374 - "@atproto/lex-document@0.0.4": 375 - version "0.0.4" 376 - resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.4.tgz#2ada83e30bcef84cc0ff010c5dd8eb2514160731" 377 - integrity sha512-oYT3MHcAkXgPfgSzSgZuBy6R/kxkIdFAr4ohcykGquyxm0ZLWFWilgKzqKiOSzFHY0SX+Q9sWKGVt5y45ebLIw== 409 + "@atproto/lex-document@0.0.5": 410 + version "0.0.5" 411 + resolved "https://registry.yarnpkg.com/@atproto/lex-document/-/lex-document-0.0.5.tgz#8d4851b351149ba673c1de9c1898c3c9ebd8b4b3" 412 + integrity sha512-faGcwsupdtvoFZ8ILEu14MYJ0z/pCiQ+Yu4WEkVtJvy8jIkFxeZ8MxxZgm8KrlSt6jxsyYvMpscQCtYpVmafFQ== 378 413 dependencies: 379 - "@atproto/lex-schema" "0.0.3" 414 + "@atproto/lex-schema" "0.0.4" 380 415 core-js "^3" 381 416 tslib "^2.8.1" 382 417 ··· 396 431 "@atproto/lex-data" "0.0.3" 397 432 tslib "^2.8.1" 398 433 399 - "@atproto/lex-resolver@0.0.4": 400 - version "0.0.4" 401 - resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.4.tgz#769b10e95860a055bce6d7dbf876f2d7612b1c7a" 402 - integrity sha512-ZEZYXGCYXhDy9kxPOr/WacXr62gg4R9zNf2VNk6Y/BZ+9hycW/rlEoLMo6rAsG8PxvA6D3h3o87z6xQZEI/oyw== 434 + "@atproto/lex-resolver@0.0.5": 435 + version "0.0.5" 436 + resolved "https://registry.yarnpkg.com/@atproto/lex-resolver/-/lex-resolver-0.0.5.tgz#9d0645c43423cb99b491ca8e658770395d2cd630" 437 + integrity sha512-gBqHY1HS/g2rxuk8CPzB1cmMxmOWK897/jIboMhYDXnuavg8zh54eljCFXr8GvaJdo5DnMGyBEilHNppJOD4mg== 403 438 dependencies: 404 439 "@atproto-labs/did-resolver" "0.2.4" 405 440 "@atproto/crypto" "0.4.5" 406 - "@atproto/lex-client" "0.0.3" 407 - "@atproto/lex-data" "0.0.2" 408 - "@atproto/lex-document" "0.0.4" 409 - "@atproto/lex-schema" "0.0.3" 410 - "@atproto/repo" "0.8.11" 441 + "@atproto/lex-client" "0.0.4" 442 + "@atproto/lex-data" "0.0.3" 443 + "@atproto/lex-document" "0.0.5" 444 + "@atproto/lex-schema" "0.0.4" 445 + "@atproto/repo" "0.8.12" 411 446 "@atproto/syntax" "0.4.2" 412 447 tslib "^2.8.1" 413 448 414 - "@atproto/lex-schema@0.0.3": 415 - version "0.0.3" 416 - resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.3.tgz#2f5a65b3592577d0056e51742f834991093a1cd3" 417 - integrity sha512-GI0YWGRxTa/qQMHfkIrWzdEALN64ZMcKjD5lHIwuggDg8a2TwLvaN0WafSnivJGZ9m7oUNu5b97MJiDoJdeAUw== 449 + "@atproto/lex-schema@0.0.4": 450 + version "0.0.4" 451 + resolved "https://registry.yarnpkg.com/@atproto/lex-schema/-/lex-schema-0.0.4.tgz#382998f5c384b864a1bc0c00bd073c1168503895" 452 + integrity sha512-WlF+w/OH16KR9XYW9J7hNEDgHp37uG2EWm8/iJknDFsWYjbhgl71x1jcqqCM8BcDxAYxyJLOvPuadOC6cG5JjA== 418 453 dependencies: 419 - "@atproto/lex-data" "0.0.2" 454 + "@atproto/lex-data" "0.0.3" 420 455 "@atproto/syntax" "0.4.2" 421 456 tslib "^2.8.1" 422 457 ··· 464 499 optionalDependencies: 465 500 "@atproto/oauth-provider-api" "0.3.4" 466 501 467 - "@atproto/oauth-provider@^0.14.0": 468 - version "0.14.0" 469 - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.14.0.tgz#bcae0a70250a0ca93853a90f75538acacbda46a4" 470 - integrity sha512-eznIEvLu7iZ6mg90R8mn+WiCFkMJywHjB0wn5a9/ajWuUWPHhNSxllWy1BXtwjLvC1g1ECAwAXAY+x3Y8yfgaA== 502 + "@atproto/oauth-provider@^0.14.1": 503 + version "0.14.1" 504 + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.14.1.tgz#b1fd01408bb988ee29f19e2ab6ab323a31ea7590" 505 + integrity sha512-n0qHOhUfSwp6w3i54tC5IjmeE5raoxEyxchJkqvoj/xWBiysdMJwpLTIvQ90rcXbjIA22Jv58oQ/mtx07w82xA== 471 506 dependencies: 472 507 "@atproto-labs/fetch" "0.2.3" 473 508 "@atproto-labs/fetch-node" "0.2.0" 474 509 "@atproto-labs/pipe" "0.1.1" 475 510 "@atproto-labs/simple-store" "0.3.0" 476 511 "@atproto-labs/simple-store-memory" "0.1.4" 477 - "@atproto/common" "^0.5.2" 512 + "@atproto/common" "^0.5.3" 478 513 "@atproto/did" "0.2.3" 479 514 "@atproto/jwk" "0.6.0" 480 515 "@atproto/jwk-jose" "0.1.11" 481 - "@atproto/lex-document" "0.0.4" 482 - "@atproto/lex-resolver" "0.0.4" 516 + "@atproto/lex-document" "0.0.5" 517 + "@atproto/lex-resolver" "0.0.5" 483 518 "@atproto/oauth-provider-api" "0.3.4" 484 519 "@atproto/oauth-provider-frontend" "0.2.5" 485 520 "@atproto/oauth-provider-ui" "0.3.6" ··· 515 550 "@atproto/jwk" "0.6.0" 516 551 zod "^3.23.8" 517 552 518 - "@atproto/ozone@^0.1.159": 519 - version "0.1.159" 520 - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.159.tgz#217408581120754d3711eb68699e213d78757052" 521 - integrity sha512-d2XVTXA0KA5hbXRgEek+jgafQ3Im6xBEJ7encsVVMc3bf6KTd7zegXmHKHddo7MS8qJbwcOgzj9N/bUQw7DuCw== 553 + "@atproto/ozone@^0.1.160": 554 + version "0.1.160" 555 + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.160.tgz#1131fa6c0d1b37e3a7b0aaa2f788b36eb2f7a750" 556 + integrity sha512-p0CP5/1Anv1qbxoRBC4IOaSkFe28Y3nKmzfrx0Z6in/o8VF/LZ9dhuW1UgrDQs4PyMzMOar3UFfO2ZUL4Gg58A== 522 557 dependencies: 523 - "@atproto/api" "^0.18.4" 524 - "@atproto/common" "^0.5.2" 558 + "@atproto/api" "^0.18.5" 559 + "@atproto/common" "^0.5.3" 525 560 "@atproto/crypto" "^0.4.5" 526 561 "@atproto/identity" "^0.4.10" 527 - "@atproto/lexicon" "^0.5.2" 562 + "@atproto/lexicon" "^0.6.0" 528 563 "@atproto/syntax" "^0.4.2" 529 564 "@atproto/ws-client" "^0.0.3" 530 - "@atproto/xrpc" "^0.7.6" 531 - "@atproto/xrpc-server" "^0.10.2" 565 + "@atproto/xrpc" "^0.7.7" 566 + "@atproto/xrpc-server" "^0.10.3" 532 567 "@did-plc/lib" "^0.0.1" 533 568 compression "^1.7.4" 534 569 cors "^2.8.5" ··· 546 581 undici "^6.14.1" 547 582 ws "^8.12.0" 548 583 549 - "@atproto/pds@^0.4.197": 550 - version "0.4.197" 551 - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.197.tgz#16992c19a6b45dfe1c802b17f6702b6af113ffd9" 552 - integrity sha512-KzPKo00/eOgsShklVLXlHT5ogKVXcOkMLt3NeEQzC3IOQRvAC4BRjPrFs2zRuQoWfTD3ClSpGVsF2zN5bQw+KQ== 584 + "@atproto/pds@^0.4.199": 585 + version "0.4.199" 586 + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.199.tgz#8cdf10ad449c57206b4820191ecfb358346908bd" 587 + integrity sha512-EtXVzPusvLVRIhjUutKcb75lPN9oWD24bZGbRrDaehcbI0QXbosGek/vgil0ZFLn0YW3G/BssM0K63KUotscrw== 553 588 dependencies: 554 589 "@atproto-labs/fetch-node" "0.2.0" 555 590 "@atproto-labs/simple-store" "0.3.0" 556 591 "@atproto-labs/simple-store-memory" "0.1.4" 557 592 "@atproto-labs/simple-store-redis" "0.0.1" 558 593 "@atproto-labs/xrpc-utils" "0.0.24" 559 - "@atproto/api" "^0.18.4" 594 + "@atproto/api" "^0.18.6" 560 595 "@atproto/aws" "^0.2.31" 561 - "@atproto/common" "^0.5.2" 596 + "@atproto/common" "^0.5.3" 562 597 "@atproto/crypto" "^0.4.5" 563 598 "@atproto/identity" "^0.4.10" 564 - "@atproto/lex-cbor" "^0.0.2" 565 - "@atproto/lex-data" "^0.0.2" 566 - "@atproto/lexicon" "^0.5.2" 567 - "@atproto/oauth-provider" "^0.14.0" 599 + "@atproto/lex-cbor" "^0.0.3" 600 + "@atproto/lex-data" "^0.0.3" 601 + "@atproto/lexicon" "^0.6.0" 602 + "@atproto/oauth-provider" "^0.14.1" 568 603 "@atproto/oauth-scopes" "^0.3.0" 569 - "@atproto/repo" "^0.8.11" 604 + "@atproto/repo" "^0.8.12" 570 605 "@atproto/syntax" "^0.4.2" 571 - "@atproto/xrpc" "^0.7.6" 572 - "@atproto/xrpc-server" "^0.10.2" 606 + "@atproto/xrpc" "^0.7.7" 607 + "@atproto/xrpc-server" "^0.10.3" 573 608 "@did-plc/lib" "^0.0.4" 574 609 "@hapi/address" "^5.1.1" 575 610 better-sqlite3 "^10.0.0" ··· 599 634 undici "^6.19.8" 600 635 zod "^3.23.8" 601 636 602 - "@atproto/repo@0.8.11", "@atproto/repo@^0.8.11": 637 + "@atproto/repo@0.8.12", "@atproto/repo@^0.8.12": 638 + version "0.8.12" 639 + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.12.tgz#f1caee2ff954a917f921569eac22ab669b39a0af" 640 + integrity sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ== 641 + dependencies: 642 + "@atproto/common" "^0.5.3" 643 + "@atproto/common-web" "^0.4.7" 644 + "@atproto/crypto" "^0.4.5" 645 + "@atproto/lexicon" "^0.6.0" 646 + "@ipld/dag-cbor" "^7.0.0" 647 + multiformats "^9.9.0" 648 + uint8arrays "3.0.0" 649 + varint "^6.0.0" 650 + zod "^3.23.8" 651 + 652 + "@atproto/repo@^0.8.11": 603 653 version "0.8.11" 604 654 resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.11.tgz#3698e4164811adbeb269fd4412639babc4be90ca" 605 655 integrity sha512-b/WCu5ITws4ILHoXiZz0XXB5U9C08fUVzkBQDwpnme62GXv8gUaAPL/ttG61OusW09ARwMMQm4vxoP0hTFg+zA== ··· 614 664 varint "^6.0.0" 615 665 zod "^3.23.8" 616 666 617 - "@atproto/sync@^0.1.38": 618 - version "0.1.38" 619 - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.38.tgz#26244d1e916e6b5c30545eb55c3c3e68cdf1b4a8" 620 - integrity sha512-2rE0SM21Nk4hWw/XcIYFnzlWO6/gBg8mrzuWbOvDhD49sA/wW4zyjaHZ5t1gvk28/SLok2VZiIR8nYBdbf7F5Q== 667 + "@atproto/sync@^0.1.39": 668 + version "0.1.39" 669 + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.39.tgz#0bb4a2a7391cfe73cf0be0a3905bc75d699ddf43" 670 + integrity sha512-JE0flkb6cDHc1dFNclkX6QB2PYXR+Taa1HDP7prI1lyFtkEASO0AOt+VtbL2JKhEa7VEy8ckko1T9glpCwGNYA== 621 671 dependencies: 622 - "@atproto/common" "^0.5.0" 672 + "@atproto/common" "^0.5.3" 623 673 "@atproto/identity" "^0.4.10" 624 - "@atproto/lexicon" "^0.5.2" 625 - "@atproto/repo" "^0.8.11" 626 - "@atproto/syntax" "^0.4.1" 627 - "@atproto/xrpc-server" "^0.10.0" 674 + "@atproto/lexicon" "^0.6.0" 675 + "@atproto/repo" "^0.8.12" 676 + "@atproto/syntax" "^0.4.2" 677 + "@atproto/xrpc-server" "^0.10.3" 628 678 multiformats "^9.9.0" 629 679 p-queue "^6.6.2" 630 680 ws "^8.12.0" ··· 647 697 "@atproto/common" "^0.5.0" 648 698 ws "^8.12.0" 649 699 650 - "@atproto/xrpc-server@^0.10.0", "@atproto/xrpc-server@^0.10.2": 700 + "@atproto/xrpc-server@^0.10.0": 651 701 version "0.10.2" 652 702 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.2.tgz#b68f42a7b6df5bb8081525e5c981a709b9b02739" 653 703 integrity sha512-5AzN8xoV8K1Omn45z6qKH414+B3Z35D536rrScwF3aQGDEdpObAS+vya9UoSg+Gvm2+oOtVEbVri7riLTBW3Vg== ··· 659 709 "@atproto/lexicon" "^0.5.2" 660 710 "@atproto/ws-client" "^0.0.3" 661 711 "@atproto/xrpc" "^0.7.6" 712 + express "^4.17.2" 713 + http-errors "^2.0.0" 714 + mime-types "^2.1.35" 715 + rate-limiter-flexible "^2.4.1" 716 + ws "^8.12.0" 717 + zod "^3.23.8" 718 + 719 + "@atproto/xrpc-server@^0.10.3": 720 + version "0.10.3" 721 + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.10.3.tgz#94daece26a697e0faa50a776e943a5ff34fb5959" 722 + integrity sha512-jcYykZ8dJDGtEsnIM3ExSeoACYgivizHgrfh/WO/b2rr48/epTPVOdupiqs2lSM7RJ6yIqir9/3Az7AD4GaZJg== 723 + dependencies: 724 + "@atproto/common" "^0.5.3" 725 + "@atproto/crypto" "^0.4.5" 726 + "@atproto/lex-cbor" "0.0.3" 727 + "@atproto/lex-data" "0.0.3" 728 + "@atproto/lexicon" "^0.6.0" 729 + "@atproto/ws-client" "^0.0.3" 730 + "@atproto/xrpc" "^0.7.7" 662 731 express "^4.17.2" 663 732 http-errors "^2.0.0" 664 733 mime-types "^2.1.35" ··· 3800 3869 resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" 3801 3870 integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== 3802 3871 3803 - "@bsky.app/alf@^0.1.5": 3804 - version "0.1.5" 3805 - resolved "https://registry.yarnpkg.com/@bsky.app/alf/-/alf-0.1.5.tgz#8de3e128a3a490625fa24dae89f8febfe4a668b2" 3806 - integrity sha512-1FGsLTUxv4UpbO3tW/oXIEzR8If/FPkFwWkTOBvpcA9cfR73QZiXXitUojCZbiXEIl0WiL+8j3zHjpNaaxiOAA== 3872 + "@bsky.app/alf@^0.1.6": 3873 + version "0.1.6" 3874 + resolved "https://registry.yarnpkg.com/@bsky.app/alf/-/alf-0.1.6.tgz#256dac988702f076b98f1963c9d594141bc74a10" 3875 + integrity sha512-8DdAOP+qRVgme+5WwqhxU20qzHXvEkEzr60SIBvDEVblCkyEiPZ36YznbpbmpT23qXbfbUFf9mb5IXYJqd8ELQ== 3807 3876 dependencies: 3808 3877 react-responsive "^10.0.1" 3809 3878 ··· 4073 4142 resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.47.0.tgz#5478fdf443ff8158f9de171c704ae45308696c7d" 4074 4143 integrity sha512-P6omY1zv5MItm93kLM8s2vr1HICJH8v0dvddDhysbIuZ+vcjOHg5Zbkf1mTkcmi2JA9oBG2anOkRnW8WJTS8Og== 4075 4144 4076 - "@expo/cli@54.0.15": 4077 - version "54.0.15" 4078 - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.15.tgz#63ca51d082fe0d683482c320f9b827c1637c01cb" 4079 - integrity sha512-tgaKFeYNRjZssPueZMm1+2cRek6mxEsthPoBX6NzQeDlzIzYBBpnAR6xH95UO6A7r0vduBeL2acIAV1Y5aSGJQ== 4145 + "@expo/cli@54.0.18": 4146 + version "54.0.18" 4147 + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.18.tgz#cd82a55bb7caa557d641d3bb4046b2dacf17ab61" 4148 + integrity sha512-hN4kolUXLah9T8DQJ8ue1ZTvRNbeNJOEOhLBak6EU7h90FKfjLA32nz99jRnHmis+aF+9qsrQG9yQx9eCSVDcg== 4080 4149 dependencies: 4081 4150 "@0no-co/graphql.web" "^1.0.8" 4082 4151 "@expo/code-signing-certificates" "^0.0.5" 4083 - "@expo/config" "~12.0.10" 4084 - "@expo/config-plugins" "~54.0.2" 4085 - "@expo/devcert" "^1.1.2" 4086 - "@expo/env" "~2.0.7" 4087 - "@expo/image-utils" "^0.8.7" 4088 - "@expo/json-file" "^10.0.7" 4089 - "@expo/mcp-tunnel" "~0.1.0" 4152 + "@expo/config" "~12.0.11" 4153 + "@expo/config-plugins" "~54.0.3" 4154 + "@expo/devcert" "^1.2.1" 4155 + "@expo/env" "~2.0.8" 4156 + "@expo/image-utils" "^0.8.8" 4157 + "@expo/json-file" "^10.0.8" 4090 4158 "@expo/metro" "~54.1.0" 4091 - "@expo/metro-config" "~54.0.8" 4092 - "@expo/osascript" "^2.3.7" 4093 - "@expo/package-manager" "^1.9.8" 4094 - "@expo/plist" "^0.4.7" 4095 - "@expo/prebuild-config" "^54.0.6" 4096 - "@expo/schema-utils" "^0.1.7" 4159 + "@expo/metro-config" "~54.0.10" 4160 + "@expo/osascript" "^2.3.8" 4161 + "@expo/package-manager" "^1.9.9" 4162 + "@expo/plist" "^0.4.8" 4163 + "@expo/prebuild-config" "^54.0.7" 4164 + "@expo/schema-utils" "^0.1.8" 4097 4165 "@expo/spawn-async" "^1.7.2" 4098 4166 "@expo/ws-tunnel" "^1.0.1" 4099 4167 "@expo/xcpretty" "^4.3.0" ··· 4111 4179 connect "^3.7.0" 4112 4180 debug "^4.3.4" 4113 4181 env-editor "^0.4.1" 4114 - expo-server "^1.0.4" 4182 + expo-server "^1.0.5" 4115 4183 freeport-async "^2.0.0" 4116 4184 getenv "^2.0.0" 4117 - glob "^10.4.2" 4185 + glob "^13.0.0" 4118 4186 lan-network "^0.1.6" 4119 4187 minimatch "^9.0.0" 4120 4188 node-forge "^1.3.1" ··· 4137 4205 source-map-support "~0.5.21" 4138 4206 stacktrace-parser "^0.1.10" 4139 4207 structured-headers "^0.4.1" 4140 - tar "^7.4.3" 4208 + tar "^7.5.2" 4141 4209 terminal-link "^2.1.1" 4142 4210 undici "^6.18.2" 4143 4211 wrap-ansi "^7.0.0" ··· 4191 4259 xcode "^3.0.1" 4192 4260 xml2js "0.6.0" 4193 4261 4194 - "@expo/config-plugins@~54.0.2": 4195 - version "54.0.2" 4196 - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.2.tgz#4760319898e1a55c0d039adaee1360cff147d454" 4197 - integrity sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg== 4198 - dependencies: 4199 - "@expo/config-types" "^54.0.8" 4200 - "@expo/json-file" "~10.0.7" 4201 - "@expo/plist" "^0.4.7" 4202 - "@expo/sdk-runtime-versions" "^1.0.0" 4203 - chalk "^4.1.2" 4204 - debug "^4.3.5" 4205 - getenv "^2.0.0" 4206 - glob "^10.4.2" 4207 - resolve-from "^5.0.0" 4208 - semver "^7.5.4" 4209 - slash "^3.0.0" 4210 - slugify "^1.6.6" 4211 - xcode "^3.0.1" 4212 - xml2js "0.6.0" 4213 - 4214 4262 "@expo/config-plugins@~54.0.3": 4215 4263 version "54.0.3" 4216 4264 resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.3.tgz#2b9ffd68a48e3b51299cdbe3ee777b9f5163fc03" ··· 4246 4294 resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-54.0.9.tgz#b9279c47fe249b774fbd3358b6abddea08f1bcec" 4247 4295 integrity sha512-Llf4jwcrAnrxgE5WCdAOxtMf8FGwS4Sk0SSgI0NnIaSyCnmOCAm80GPFvsK778Oj19Ub4tSyzdqufPyeQPksWw== 4248 4296 4249 - "@expo/config@~12.0.10": 4250 - version "12.0.10" 4251 - resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.10.tgz#18acc0a2d5994dc167d1d4faca3e939de2bb95de" 4252 - integrity sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w== 4253 - dependencies: 4254 - "@babel/code-frame" "~7.10.4" 4255 - "@expo/config-plugins" "~54.0.2" 4256 - "@expo/config-types" "^54.0.8" 4257 - "@expo/json-file" "^10.0.7" 4258 - deepmerge "^4.3.1" 4259 - getenv "^2.0.0" 4260 - glob "^10.4.2" 4261 - require-from-string "^2.0.2" 4262 - resolve-from "^5.0.0" 4263 - resolve-workspace-root "^2.0.0" 4264 - semver "^7.6.0" 4265 - slugify "^1.3.4" 4266 - sucrase "3.35.0" 4267 - 4268 4297 "@expo/config@~12.0.11": 4269 4298 version "12.0.11" 4270 4299 resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.11.tgz#b9f1cecd6bac4c2101bb8489b918556dc100434f" ··· 4284 4313 slugify "^1.3.4" 4285 4314 sucrase "~3.35.1" 4286 4315 4287 - "@expo/config@~12.0.8", "@expo/config@~12.0.9": 4316 + "@expo/config@~12.0.8": 4288 4317 version "12.0.9" 4289 4318 resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.9.tgz#07e1ddb3c9227031e9e9322e41797ad36197a1c3" 4290 4319 integrity sha512-HiDVVaXYKY57+L1MxSF3TaYjX6zZlGBnuWnOKZG+7mtsLD+aNTtW4bZM0pZqZfoRumyOU0SfTCwT10BWtUUiJQ== ··· 4303 4332 slugify "^1.3.4" 4304 4333 sucrase "3.35.0" 4305 4334 4306 - "@expo/devcert@^1.1.2": 4307 - version "1.1.2" 4308 - resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.1.2.tgz#a4923b8ea5b34fde31d6e006a40d0f594096a0ed" 4309 - integrity sha512-FyWghLu7rUaZEZSTLt/XNRukm0c9GFfwP0iFaswoDWpV6alvVg+zRAfCLdIVQEz1SVcQ3zo1hMZFDrnKGvkCuQ== 4335 + "@expo/devcert@^1.2.1": 4336 + version "1.2.1" 4337 + resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.2.1.tgz#1a687985bea1670866e54d5ba7c0ced963c354f4" 4338 + integrity sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA== 4310 4339 dependencies: 4311 - application-config-path "^0.1.0" 4312 - command-exists "^1.2.4" 4340 + "@expo/sudo-prompt" "^9.3.1" 4313 4341 debug "^3.1.0" 4314 - eol "^0.9.1" 4315 - get-port "^3.2.0" 4316 - glob "^7.1.2" 4317 - lodash "^4.17.21" 4318 - mkdirp "^0.5.1" 4319 - password-prompt "^1.0.4" 4320 - rimraf "^2.6.2" 4321 - sudo-prompt "^8.2.0" 4322 - tmp "^0.0.33" 4323 - tslib "^2.4.0" 4324 4342 4325 - "@expo/devtools@0.1.7": 4326 - version "0.1.7" 4327 - resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-0.1.7.tgz#bf4f552168ebd44c9fe63941bc4806fe2d222899" 4328 - integrity sha512-dfIa9qMyXN+0RfU6SN4rKeXZyzKWsnz6xBSDccjL4IRiE+fQ0t84zg0yxgN4t/WK2JU5v6v4fby7W7Crv9gJvA== 4343 + "@expo/devtools@0.1.8": 4344 + version "0.1.8" 4345 + resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-0.1.8.tgz#bc5b297698f78b3b67037f04593a31e688330a7a" 4346 + integrity sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ== 4329 4347 dependencies: 4330 4348 chalk "^4.1.2" 4331 4349 ··· 4340 4358 dotenv-expand "~11.0.6" 4341 4359 getenv "^2.0.0" 4342 4360 4343 - "@expo/fingerprint@0.15.3": 4344 - version "0.15.3" 4345 - resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.3.tgz#26e7231d1ebd69a375c02ba595bba7b06fe882bb" 4346 - integrity sha512-8YPJpEYlmV171fi+t+cSLMX1nC5ngY9j2FiN70dHldLpd6Ct6ouGhk96svJ4BQZwsqwII2pokwzrDAwqo4Z0FQ== 4361 + "@expo/env@~2.0.8": 4362 + version "2.0.8" 4363 + resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.0.8.tgz#2aea906eed3d297b2e19608dc1a800fba0a3fe03" 4364 + integrity sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA== 4365 + dependencies: 4366 + chalk "^4.0.0" 4367 + debug "^4.3.4" 4368 + dotenv "~16.4.5" 4369 + dotenv-expand "~11.0.6" 4370 + getenv "^2.0.0" 4371 + 4372 + "@expo/fingerprint@0.15.4": 4373 + version "0.15.4" 4374 + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.4.tgz#578bd1e1179a13313f7de682ebaaacb703b2b7ac" 4375 + integrity sha512-eYlxcrGdR2/j2M6pEDXo9zU9KXXF1vhP+V+Tl+lyY+bU8lnzrN6c637mz6Ye3em2ANy8hhUR03Raf8VsT9Ogng== 4347 4376 dependencies: 4348 4377 "@expo/spawn-async" "^1.7.2" 4349 4378 arg "^5.0.2" 4350 4379 chalk "^4.1.2" 4351 4380 debug "^4.3.4" 4352 4381 getenv "^2.0.0" 4353 - glob "^10.4.2" 4382 + glob "^13.0.0" 4354 4383 ignore "^5.3.1" 4355 4384 minimatch "^9.0.0" 4356 4385 p-limit "^3.1.0" ··· 4362 4391 resolved "https://registry.yarnpkg.com/@expo/html-elements/-/html-elements-0.12.5.tgz#be7e7af9f2be6d3f1aa3ec2e7ae1c121c91a9aa1" 4363 4392 integrity sha512-28KWO88YKykKU7ke5sEQs5TivFRMs1Aktz13xxgqAf5rTgb+lka0VKVt3W2fG7ksbUQ407rtUqz7SEAq298NvQ== 4364 4393 4365 - "@expo/image-utils@0.3.23", "@expo/image-utils@0.8.7", "@expo/image-utils@^0.8.7": 4394 + "@expo/image-utils@0.3.23", "@expo/image-utils@0.8.7", "@expo/image-utils@^0.8.7", "@expo/image-utils@^0.8.8": 4366 4395 version "0.8.7" 4367 4396 resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.7.tgz#3e765005def8a4e5533155b0042e053ebfafc9d2" 4368 4397 integrity sha512-SXOww4Wq3RVXLyOaXiCCuQFguCDh8mmaHBv54h/R29wGl4jRY8GEyQEx8SypV/iHt1FbzsU/X3Qbcd9afm2W2w== ··· 4403 4432 json5 "^2.2.3" 4404 4433 write-file-atomic "^2.3.0" 4405 4434 4406 - "@expo/mcp-tunnel@~0.1.0": 4407 - version "0.1.0" 4408 - resolved "https://registry.yarnpkg.com/@expo/mcp-tunnel/-/mcp-tunnel-0.1.0.tgz#ae4ce4320b2f97a9891783c2316f9936c912d126" 4409 - integrity sha512-rJ6hl0GnIZj9+ssaJvFsC7fwyrmndcGz+RGFzu+0gnlm78X01957yjtHgjcmnQAgL5hWEOR6pkT0ijY5nU5AWw== 4410 - dependencies: 4411 - ws "^8.18.3" 4412 - zod "^3.25.76" 4413 - zod-to-json-schema "^3.24.6" 4414 - 4415 - "@expo/metro-config@54.0.8", "@expo/metro-config@~54.0.8": 4416 - version "54.0.8" 4417 - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.8.tgz#7e5bf551b23e8f4c8ec20504184e0a9988ffa86e" 4418 - integrity sha512-rCkDQ8IT6sgcGNy48O2cTE4NlazCAgAIsD5qBsNPJLZSS0XbaILvAgGsFt/4nrx0GMGj6iQcOn5ifwV4NssTmw== 4435 + "@expo/metro-config@54.0.10", "@expo/metro-config@~54.0.10": 4436 + version "54.0.10" 4437 + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.10.tgz#f15ecb8cea476a3f27b182a87b8740291395170e" 4438 + integrity sha512-AkSTwaWbMMDOiV4RRy4Mv6MZEOW5a7BZlgtrWxvzs6qYKRxKLKH/qqAuKe0bwGepF1+ws9oIX5nQjtnXRwezvQ== 4419 4439 dependencies: 4420 4440 "@babel/code-frame" "^7.20.0" 4421 4441 "@babel/core" "^7.20.0" 4422 4442 "@babel/generator" "^7.20.5" 4423 - "@expo/config" "~12.0.10" 4443 + "@expo/config" "~12.0.11" 4424 4444 "@expo/env" "~2.0.7" 4425 4445 "@expo/json-file" "~10.0.7" 4426 4446 "@expo/metro" "~54.1.0" ··· 4431 4451 dotenv "~16.4.5" 4432 4452 dotenv-expand "~11.0.6" 4433 4453 getenv "^2.0.0" 4434 - glob "^10.4.2" 4454 + glob "^13.0.0" 4435 4455 hermes-parser "^0.29.1" 4436 4456 jsc-safe-url "^0.2.4" 4437 4457 lightningcss "^1.30.1" ··· 4457 4477 metro-transform-plugins "0.83.2" 4458 4478 metro-transform-worker "0.83.2" 4459 4479 4460 - "@expo/osascript@^2.3.7": 4461 - version "2.3.7" 4462 - resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.3.7.tgz#2d53ef06733593405c83767de7420510736e0fa9" 4463 - integrity sha512-IClSOXxR0YUFxIriUJVqyYki7lLMIHrrzOaP01yxAL1G8pj2DWV5eW1y5jSzIcIfSCNhtGsshGd1tU/AYup5iQ== 4480 + "@expo/osascript@^2.3.8": 4481 + version "2.3.8" 4482 + resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.3.8.tgz#6b376f09650e6476991f707356be54b5ea53d89e" 4483 + integrity sha512-/TuOZvSG7Nn0I8c+FcEaoHeBO07yu6vwDgk7rZVvAXoeAK5rkA09jRyjYsZo+0tMEFaToBeywA6pj50Mb3ny9w== 4464 4484 dependencies: 4465 4485 "@expo/spawn-async" "^1.7.2" 4466 4486 exec-async "^2.2.0" 4467 4487 4468 - "@expo/package-manager@^1.9.8": 4469 - version "1.9.8" 4470 - resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.9.8.tgz#8f6b46a2f5f4bf4f2c78507b1a7a368e0c2e2126" 4471 - integrity sha512-4/I6OWquKXYnzo38pkISHCOCOXxfeEmu4uDoERq1Ei/9Ur/s9y3kLbAamEkitUkDC7gHk1INxRWEfFNzGbmOrA== 4488 + "@expo/package-manager@^1.9.9": 4489 + version "1.9.9" 4490 + resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.9.9.tgz#dd030d2bccebd095e02bfb6976852afaddcd122a" 4491 + integrity sha512-Nv5THOwXzPprMJwbnXU01iXSrCp3vJqly9M4EJ2GkKko9Ifer2ucpg7x6OUsE09/lw+npaoUnHMXwkw7gcKxlg== 4472 4492 dependencies: 4473 - "@expo/json-file" "^10.0.7" 4493 + "@expo/json-file" "^10.0.8" 4474 4494 "@expo/spawn-async" "^1.7.2" 4475 4495 chalk "^4.0.0" 4476 4496 npm-package-arg "^11.0.0" ··· 4495 4515 base64-js "^1.2.3" 4496 4516 xmlbuilder "^15.1.1" 4497 4517 4498 - "@expo/prebuild-config@^54.0.3": 4499 - version "54.0.3" 4500 - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.3.tgz#e3ac24bb1ec2ec348dfa98dc2ec5605b1fa49bf3" 4501 - integrity sha512-okf6Umaz1VniKmm+pA37QHBzB9XlRHvO1Qh3VbUezy07LTkz87kXUW7uLMmrA319WLavWSVORTXeR0jBRihObA== 4518 + "@expo/plist@^0.4.8": 4519 + version "0.4.8" 4520 + resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.4.8.tgz#e014511a4a5008cf2b832b91caa8e9f2704127cc" 4521 + integrity sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ== 4502 4522 dependencies: 4503 - "@expo/config" "~12.0.9" 4504 - "@expo/config-plugins" "~54.0.1" 4505 - "@expo/config-types" "^54.0.8" 4506 - "@expo/image-utils" "^0.8.7" 4507 - "@expo/json-file" "^10.0.7" 4508 - "@react-native/normalize-colors" "0.81.4" 4509 - debug "^4.3.1" 4510 - resolve-from "^5.0.0" 4511 - semver "^7.6.0" 4512 - xml2js "0.6.0" 4523 + "@xmldom/xmldom" "^0.8.8" 4524 + base64-js "^1.2.3" 4525 + xmlbuilder "^15.1.1" 4513 4526 4514 - "@expo/prebuild-config@^54.0.6": 4515 - version "54.0.6" 4516 - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.6.tgz#0f0daed0195efbb33d91a730052b208507f94eed" 4517 - integrity sha512-xowuMmyPNy+WTNq+YX0m0EFO/Knc68swjThk4dKivgZa8zI1UjvFXOBIOp8RX4ljCXLzwxQJM5oBBTvyn+59ZA== 4527 + "@expo/prebuild-config@^54.0.7": 4528 + version "54.0.7" 4529 + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.7.tgz#b8b0769baca750495abc936c00670a5e37a1780d" 4530 + integrity sha512-cKqBsiwcFFzpDWgtvemrCqJULJRLDLKo2QMF74NusoGNpfPI3vQVry1iwnYLeGht02AeD3dvfhpqBczD3wchxA== 4518 4531 dependencies: 4519 - "@expo/config" "~12.0.10" 4520 - "@expo/config-plugins" "~54.0.2" 4521 - "@expo/config-types" "^54.0.8" 4522 - "@expo/image-utils" "^0.8.7" 4523 - "@expo/json-file" "^10.0.7" 4532 + "@expo/config" "~12.0.11" 4533 + "@expo/config-plugins" "~54.0.3" 4534 + "@expo/config-types" "^54.0.9" 4535 + "@expo/image-utils" "^0.8.8" 4536 + "@expo/json-file" "^10.0.8" 4524 4537 "@react-native/normalize-colors" "0.81.5" 4525 4538 debug "^4.3.1" 4526 4539 resolve-from "^5.0.0" 4527 4540 semver "^7.6.0" 4528 4541 xml2js "0.6.0" 4529 4542 4530 - "@expo/schema-utils@^0.1.7": 4531 - version "0.1.7" 4532 - resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.7.tgz#38baa0effa0823cd4eca3f05e5eee3bde311da12" 4533 - integrity sha512-jWHoSuwRb5ZczjahrychMJ3GWZu54jK9ulNdh1d4OzAEq672K9E5yOlnlBsfIHWHGzUAT+0CL7Yt1INiXTz68g== 4543 + "@expo/schema-utils@^0.1.8": 4544 + version "0.1.8" 4545 + resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.8.tgz#8b9543d77fc4ac4954196e3fa00f8fcedd71426a" 4546 + integrity sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A== 4534 4547 4535 4548 "@expo/sdk-runtime-versions@^1.0.0": 4536 4549 version "1.0.0" ··· 4544 4557 dependencies: 4545 4558 cross-spawn "^7.0.3" 4546 4559 4560 + "@expo/sudo-prompt@^9.3.1": 4561 + version "9.3.2" 4562 + resolved "https://registry.yarnpkg.com/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz#0fd2813402a42988e49145cab220e25bea74b308" 4563 + integrity sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw== 4564 + 4547 4565 "@expo/vector-icons@^15.0.3": 4548 4566 version "15.0.3" 4549 4567 resolved "https://registry.yarnpkg.com/@expo/vector-icons/-/vector-icons-15.0.3.tgz#12c38d4e6cc927dd0500e4591ac00672a8909748" ··· 6513 6531 resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz#2ca68188c8fff9b951f507b1dec7efe928848274" 6514 6532 integrity sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w== 6515 6533 6516 - "@react-native/normalize-colors@0.81.4", "@react-native/normalize-colors@0.81.5", "@react-native/normalize-colors@^0.74.1": 6534 + "@react-native/normalize-colors@0.81.5", "@react-native/normalize-colors@^0.74.1": 6517 6535 version "0.81.5" 6518 6536 resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz#1ca6cb6772bb7324df2b11aab35227eacd6bdfe7" 6519 6537 integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== ··· 8392 8410 resolved "https://registry.yarnpkg.com/anser/-/anser-1.4.10.tgz#befa3eddf282684bd03b63dcda3927aef8c2e35b" 8393 8411 integrity sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww== 8394 8412 8395 - ansi-escapes@^4.2.1, ansi-escapes@^4.3.0, ansi-escapes@^4.3.2: 8413 + ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: 8396 8414 version "4.3.2" 8397 8415 resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" 8398 8416 integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== ··· 8475 8493 normalize-path "^3.0.0" 8476 8494 picomatch "^2.0.4" 8477 8495 8478 - application-config-path@^0.1.0: 8479 - version "0.1.1" 8480 - resolved "https://registry.yarnpkg.com/application-config-path/-/application-config-path-0.1.1.tgz#8b5ac64ff6afdd9bd70ce69f6f64b6998f5f756e" 8481 - integrity sha512-zy9cHePtMP0YhwG+CfHm0bgwdnga2X3gZexpdCwEj//dpb+TKajtiC8REEUJUSq6Ab4f9cgNy2l8ObXzCXFkEw== 8482 - 8483 8496 arg@4.1.0: 8484 8497 version "4.1.0" 8485 8498 resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0" ··· 8985 8998 debug "^4.3.4" 8986 8999 resolve-from "^5.0.0" 8987 9000 8988 - babel-preset-expo@~54.0.6: 8989 - version "54.0.6" 8990 - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-54.0.6.tgz#a0158c7a4eb7f52c8830d6e2bfdfa329043eaee2" 8991 - integrity sha512-GxJfwnuOPQJbzDe5WASJZdNQiukLw7i9z+Lh6JQWkUHXsShHyQrqgiKE55MD/KaP9VqJ70yZm7bYqOu8zwcWqQ== 9001 + babel-preset-expo@~54.0.8: 9002 + version "54.0.8" 9003 + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-54.0.8.tgz#13e084fe0b8259d772037eb18ed5b10af7d34661" 9004 + integrity sha512-3ZJ4Q7uQpm8IR/C9xbKhE/IUjGpLm+OIjF8YCedLgqoe/wN1Ns2wLT7HwG6ZXXb6/rzN8IMCiKFQ2F93qlN6GA== 8992 9005 dependencies: 8993 9006 "@babel/helper-module-imports" "^7.25.9" 8994 9007 "@babel/plugin-proposal-decorators" "^7.12.9" ··· 9683 9696 dependencies: 9684 9697 delayed-stream "~1.0.0" 9685 9698 9686 - command-exists@^1.2.4: 9687 - version "1.2.9" 9688 - resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" 9689 - integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== 9690 - 9691 9699 commander@11.0.0: 9692 9700 version "11.0.0" 9693 9701 resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" ··· 10681 10689 resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" 10682 10690 integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== 10683 10691 10684 - eol@^0.9.1: 10685 - version "0.9.1" 10686 - resolved "https://registry.yarnpkg.com/eol/-/eol-0.9.1.tgz#f701912f504074be35c6117a5c4ade49cd547acd" 10687 - integrity sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg== 10688 - 10689 10692 error-ex@^1.3.1: 10690 10693 version "1.3.2" 10691 10694 resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" ··· 11379 11382 jest-message-util "^29.7.0" 11380 11383 jest-util "^29.7.0" 11381 11384 11382 - expo-application@~7.0.7: 11383 - version "7.0.7" 11384 - resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.7.tgz#8b2802650381042baa3b74297cdeb5f9628b7c6c" 11385 - integrity sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg== 11385 + expo-application@~7.0.8: 11386 + version "7.0.8" 11387 + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db" 11388 + integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== 11386 11389 11387 - expo-asset@~12.0.9: 11388 - version "12.0.9" 11389 - resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.9.tgz#e5b06b793bfc45a76b70a2253862351effa42e73" 11390 - integrity sha512-vrdRoyhGhBmd0nJcssTSk1Ypx3Mbn/eXaaBCQVkL0MJ8IOZpAObAjfD5CTy8+8RofcHEQdh3wwZVCs7crvfOeg== 11390 + expo-asset@~12.0.11: 11391 + version "12.0.11" 11392 + resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.11.tgz#f1905538f35b7e3d6f730efbc633ce596d88f16d" 11393 + integrity sha512-pnK/gQ5iritDPBeK54BV35ZpG7yeW5DtgGvJHruIXkyDT9BCoQq3i0AAxfcWG/e4eiRmTzAt5kNVYFJi48uo+A== 11391 11394 dependencies: 11392 - "@expo/image-utils" "^0.8.7" 11393 - expo-constants "~18.0.9" 11395 + "@expo/image-utils" "^0.8.8" 11396 + expo-constants "~18.0.11" 11394 11397 11395 - expo-blur@~15.0.7: 11396 - version "15.0.7" 11397 - resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-15.0.7.tgz#a11466e697fbf2b272444a38722065e60d0ecbe5" 11398 - integrity sha512-SugQQbQd+zRPy8z2G5qDD4NqhcD7srBF7fN7O7yq6q7ZFK59VWvpDxtMoUkmSfdxgqONsrBN/rLdk00USADrMg== 11398 + expo-blur@~15.0.8: 11399 + version "15.0.8" 11400 + resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-15.0.8.tgz#846cea275a6644639c5c338987b4cca54cf93eca" 11401 + integrity sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w== 11399 11402 11400 - expo-build-properties@~1.0.9: 11401 - version "1.0.9" 11402 - resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.9.tgz#71f0ce813a8431937a3db25a91f1bb4b1a6214ae" 11403 - integrity sha512-2icttCy3OPTk/GWIFt+vwA+0hup53jnmYb7JKRbvNvrrOrz+WblzpeoiaOleI2dYG/vjwpNO8to8qVyKhYJtrQ== 11403 + expo-build-properties@~1.0.10: 11404 + version "1.0.10" 11405 + resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546" 11406 + integrity sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q== 11404 11407 dependencies: 11405 11408 ajv "^8.11.0" 11406 11409 semver "^7.6.0" 11407 11410 11408 - expo-camera@~17.0.9: 11409 - version "17.0.9" 11410 - resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.9.tgz#4447e63960c9b4485869e2f1fac0ef083c38669a" 11411 - integrity sha512-KgticPGurqEsaPBIwbG0T6mzAVnqZasDdM/6OoJt5zPh6tWB09+th6cBF1WafIBMPy8AWbfyUQSqQXqOrNJClg== 11411 + expo-camera@~17.0.10: 11412 + version "17.0.10" 11413 + resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-17.0.10.tgz#b3a217f0eb811a6e3522c2aff9f42be578aa6456" 11414 + integrity sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg== 11412 11415 dependencies: 11413 11416 invariant "^2.2.4" 11414 11417 11415 - expo-clipboard@~8.0.7: 11416 - version "8.0.7" 11417 - resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.7.tgz#f81d279036408bbe074ef748623e1ae6f170d391" 11418 - integrity sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q== 11418 + expo-clipboard@~8.0.8: 11419 + version "8.0.8" 11420 + resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7" 11421 + integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== 11419 11422 11420 - expo-constants@18.0.8, expo-constants@^13.0.2, expo-constants@~18.0.10, expo-constants@~18.0.8, expo-constants@~18.0.9: 11423 + expo-constants@18.0.8, expo-constants@^13.0.2, expo-constants@~18.0.11: 11421 11424 version "18.0.8" 11422 11425 resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.8.tgz#14f8388136de6e83d651bd68b326a675dfb7051c" 11423 11426 integrity sha512-Tetphsx6RVImCTZeBAclRQMy0WOODY3y6qrUoc88YGUBVm8fAKkErCSWxLTCc6nFcJxdoOMYi62LgNIUFjZCLA== ··· 11425 11428 "@expo/config" "~12.0.8" 11426 11429 "@expo/env" "~2.0.7" 11427 11430 11428 - expo-dev-client@~6.0.16: 11429 - version "6.0.16" 11430 - resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.16.tgz#5e76d783f2e002e3a2b91527ed42611473e6e891" 11431 - integrity sha512-8GLud/dtNteqChL9pNGqLBSHd7of2scFmsgN5WwWgtt2dET7+EJM/K1zp0FYUzfmIF5NLsf5xUDg6AjDldOLqg== 11431 + expo-contacts@^15.0.10: 11432 + version "15.0.11" 11433 + resolved "https://registry.yarnpkg.com/expo-contacts/-/expo-contacts-15.0.11.tgz#f3164a1d8298d41b0bac9fcb3500bfa6027aa18b" 11434 + integrity sha512-buKkoQuWJgVSEvR1Yloa5SvGC/ud7T2YeYDe0R7Aa0oz8pwQpjk3W7OoL3QrXIfYcuKTrIz6ffqC8lc3peaAyg== 11435 + 11436 + expo-dev-client@~6.0.20: 11437 + version "6.0.20" 11438 + resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.20.tgz#d5b65974785100ae7f2538e16701fa1ef55f5fad" 11439 + integrity sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA== 11432 11440 dependencies: 11433 - expo-dev-launcher "6.0.16" 11434 - expo-dev-menu "7.0.15" 11441 + expo-dev-launcher "6.0.20" 11442 + expo-dev-menu "7.0.18" 11435 11443 expo-dev-menu-interface "2.0.0" 11436 - expo-manifests "~1.0.8" 11444 + expo-manifests "~1.0.10" 11437 11445 expo-updates-interface "~2.0.0" 11438 11446 11439 - expo-dev-launcher@6.0.16: 11440 - version "6.0.16" 11441 - resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.16.tgz#1527f1cea70371e9443b26e1802f3327a4c386f4" 11442 - integrity sha512-OVg5T5ip7evh8zHJeIj2IYgtvTeY8EOiwNQYlmN0JHAw8wlUxYHnSf08RcevVgYTKcIryCyeLG5UHxsQQWbycA== 11447 + expo-dev-launcher@6.0.20: 11448 + version "6.0.20" 11449 + resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.20.tgz#b2ce90ff6af4c4de28bc1ea595b0b504ed9b467d" 11450 + integrity sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA== 11443 11451 dependencies: 11444 - expo-dev-menu "7.0.15" 11445 - expo-manifests "~1.0.8" 11452 + ajv "^8.11.0" 11453 + expo-dev-menu "7.0.18" 11454 + expo-manifests "~1.0.10" 11446 11455 11447 11456 expo-dev-menu-interface@2.0.0: 11448 11457 version "2.0.0" 11449 11458 resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz#c0d6db65eb4abc44a2701bc2303744619ad05ca6" 11450 11459 integrity sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw== 11451 11460 11452 - expo-dev-menu@7.0.15: 11453 - version "7.0.15" 11454 - resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.15.tgz#de08beb63e073486ed2bfe2b736f4a1eca8d65df" 11455 - integrity sha512-aThUhoBUuQVbCS2k0MwP28/au46FqOXAAiGtCYIWp+Hne95RgFO+KaO0VGksJFwK7I9IPbbminm8ijZDf2KzXg== 11461 + expo-dev-menu@7.0.18: 11462 + version "7.0.18" 11463 + resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.18.tgz#4f3e2dc20b82fc495afb602301b83fa16430f6b8" 11464 + integrity sha512-4kTdlHrnZCAWCT6tZRQHSSjZ7vECFisL4T+nsG/GJDo/jcHNaOVGV5qPV9wzlTxyMk3YOPggRw4+g7Ownrg5eA== 11456 11465 dependencies: 11457 11466 expo-dev-menu-interface "2.0.0" 11458 11467 ··· 11463 11472 dependencies: 11464 11473 ua-parser-js "^0.7.33" 11465 11474 11466 - expo-device@~8.0.9: 11467 - version "8.0.9" 11468 - resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.9.tgz#def4fcc2f2bd99c2f009424610c298acc1c01eb6" 11469 - integrity sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA== 11475 + expo-device@~8.0.10: 11476 + version "8.0.10" 11477 + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8" 11478 + integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== 11470 11479 dependencies: 11471 11480 ua-parser-js "^0.7.33" 11472 11481 11473 - expo-eas-client@~1.0.7: 11474 - version "1.0.7" 11475 - resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.7.tgz#9c9c7909d7bb9b6ceb4bef6875f1b9119ef22a8c" 11476 - integrity sha512-Q/b1X0fM+3beqqvffok14pjxMF600NxopdSr9WJY61fF4xllcVnALS0kEudffp9ihMOfcb5xWYqzKj6jMqYDIw== 11482 + expo-eas-client@~1.0.8: 11483 + version "1.0.8" 11484 + resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-1.0.8.tgz#f1fa7cbc6b6000046119466c6ded8a77e5a4b1f8" 11485 + integrity sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA== 11477 11486 11478 - expo-file-system@~19.0.17: 11479 - version "19.0.17" 11480 - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.17.tgz#2555c05c26a19038d005f281b11dbda9722d0c0d" 11481 - integrity sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g== 11487 + expo-file-system@~19.0.20: 11488 + version "19.0.20" 11489 + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.20.tgz#57850ecbca885c50e27eed59917fbb2742fd36c0" 11490 + integrity sha512-Jr/nNvJmUlptS3cHLKVBNyTyGMHNyxYBKRph1KRe0Nb3RzZza1gZLZXMG5Ky//sO2azTn+OaT0dv/lAyL0vJNA== 11482 11491 11483 - expo-font@~14.0.9: 11484 - version "14.0.9" 11485 - resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-14.0.9.tgz#552d66afc8c6efa8839f5f4063c2e0226b3edad1" 11486 - integrity sha512-xCoQbR/36qqB6tew/LQ6GWICpaBmHLhg/Loix5Rku/0ZtNaXMJv08M9o1AcrdiGTn/Xf/BnLu6DgS45cWQEHZg== 11492 + expo-font@~14.0.10: 11493 + version "14.0.10" 11494 + resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-14.0.10.tgz#33fb9f6dc5661729192a6bc8cd6f08bd1a9097cc" 11495 + integrity sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q== 11487 11496 dependencies: 11488 11497 fontfaceobserver "^2.1.0" 11489 11498 11490 - expo-haptics@~15.0.7: 11491 - version "15.0.7" 11492 - resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.7.tgz#384bb873d7eca7b141f85e4f300b75eab68ebfe9" 11493 - integrity sha512-7flWsYPrwjJxZ8x82RiJtzsnk1Xp9ahnbd9PhCy3NnsemyMApoWIEUr4waPqFr80DtiLZfhD9VMLL1CKa8AImQ== 11499 + expo-haptics@~15.0.8: 11500 + version "15.0.8" 11501 + resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3" 11502 + integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== 11494 11503 11495 11504 expo-image-crop-tool@^0.4.0: 11496 11505 version "0.4.0" ··· 11502 11511 resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072" 11503 11512 integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ== 11504 11513 11505 - expo-image-manipulator@~14.0.7: 11506 - version "14.0.7" 11507 - resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-14.0.7.tgz#e0798d849bcb4e58b570cb74159fd1ffb56edb5e" 11508 - integrity sha512-NMHssudagLTAT6ZQ2upnJYT+gVLAt5vC+iD+TBIdV3ZS44yhrq+p2gCrYahDvtVywfmTI5WsbH+Sh1BXbmJUQw== 11514 + expo-image-manipulator@~14.0.8: 11515 + version "14.0.8" 11516 + resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz#1c457acbd2bcabe987fbd650c0f29120c3366ba6" 11517 + integrity sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q== 11509 11518 dependencies: 11510 11519 expo-image-loader "~6.0.0" 11511 11520 11512 - expo-image-picker@~17.0.8: 11513 - version "17.0.8" 11514 - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.8.tgz#c4430994de81f3e9995c9ce77cb76403213b17d7" 11515 - integrity sha512-489ByhVs2XPoAu9zodivAKLv7hG4S/FOe8hO/C2U6jVxmRjpAKakKNjMml0IwWjf1+c/RYBqm1XxKaZ+vq/fDQ== 11521 + expo-image-picker@~17.0.9: 11522 + version "17.0.9" 11523 + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.9.tgz#b4980c228d58048fd8c275b5ab27793097f53991" 11524 + integrity sha512-v40HcX1fXDlNdc5y88ygvGyZU/QrcybBajYgVVLaWrXXGBqqC8Yu8jOHR99BtIdljiQ6JfYfxbeDp1woiyUrTA== 11516 11525 dependencies: 11517 11526 expo-image-loader "~6.0.0" 11518 11527 11519 - expo-image@~3.0.10: 11520 - version "3.0.10" 11521 - resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.10.tgz#a589098c3688d76c6e238ae90e1efc06ac4902b0" 11522 - integrity sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ== 11528 + expo-image@~3.0.11: 11529 + version "3.0.11" 11530 + resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.11.tgz#54195565dc710e632c10414c3609deebb7149ac5" 11531 + integrity sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA== 11523 11532 11524 - expo-intent-launcher@~13.0.7: 11525 - version "13.0.7" 11526 - resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-13.0.7.tgz#c86488d47f8fba908a9dd0d1460a3ea06b0cefdc" 11527 - integrity sha512-4em7utK59gftgBwokpw+TQkyY27C5JH28LLrM/ZTABIsAMRUEqS+Inzd/xtN0hvxo2Z8aTsd+N1WRcCdOehYdg== 11533 + expo-intent-launcher@~13.0.8: 11534 + version "13.0.8" 11535 + resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz#74093cc2c7d49b892eadb1ff42798259eb93405f" 11536 + integrity sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A== 11528 11537 11529 11538 expo-json-utils@~0.15.0: 11530 11539 version "0.15.0" 11531 11540 resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz#6723574814b9e6b0a90e4e23662be76123ab6ae9" 11532 11541 integrity sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ== 11533 11542 11534 - expo-keep-awake@^15.0.7, expo-keep-awake@~15.0.7: 11535 - version "15.0.7" 11536 - resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.7.tgz#4eada556e1cca6c9c2e5aa39478fd01816cd0bc9" 11537 - integrity sha512-CgBNcWVPnrIVII5G54QDqoE125l+zmqR4HR8q+MQaCfHet+dYpS5vX5zii/RMayzGN4jPgA4XYIQ28ePKFjHoA== 11543 + expo-keep-awake@~15.0.8: 11544 + version "15.0.8" 11545 + resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d" 11546 + integrity sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ== 11538 11547 11539 - expo-linear-gradient@~15.0.7: 11540 - version "15.0.7" 11541 - resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz#d1ebbda5ecc58afb58398f5a06affd0e83894149" 11542 - integrity sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA== 11548 + expo-linear-gradient@~15.0.8: 11549 + version "15.0.8" 11550 + resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz#17cb49522699d2a033eae93146bc820ef180f4f8" 11551 + integrity sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw== 11543 11552 11544 - expo-linking@~8.0.8: 11545 - version "8.0.8" 11546 - resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.8.tgz#ad348c133d048043990177f67dfb6a89bf628a6e" 11547 - integrity sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg== 11553 + expo-linking@~8.0.10: 11554 + version "8.0.10" 11555 + resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.10.tgz#9108dabf97085ea93fa5036d64143bb07ffc553d" 11556 + integrity sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA== 11548 11557 dependencies: 11549 - expo-constants "~18.0.8" 11558 + expo-constants "~18.0.11" 11550 11559 invariant "^2.2.4" 11551 11560 11552 - expo-localization@~17.0.7: 11553 - version "17.0.7" 11554 - resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-17.0.7.tgz#24559be23cb7d9757fd8f8c88380d7b4ee2339a2" 11555 - integrity sha512-ACg1B0tJLNa+f8mZfAaNrMyNzrrzHAARVH1sHHvh+LolKdQpgSKX69Uroz1Llv4C71furpwBklVStbNcEwVVVA== 11561 + expo-localization@~17.0.8: 11562 + version "17.0.8" 11563 + resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-17.0.8.tgz#eb74ae0f9b5b49596322d68d2005662346211100" 11564 + integrity sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g== 11556 11565 dependencies: 11557 11566 rtl-detect "^1.0.2" 11558 11567 11559 - expo-location@~19.0.7: 11560 - version "19.0.7" 11561 - resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.7.tgz#58ab5b9b59db3a26d0495c19e719d5f559948b1c" 11562 - integrity sha512-YNkh4r9E6ECbPkBCAMG5A5yHDgS0pw+Rzyd0l2ZQlCtjkhlODB55nMCKr5CZnUI0mXTkaSm8CwfoCO8n2MpYfg== 11568 + expo-location@~19.0.8: 11569 + version "19.0.8" 11570 + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55" 11571 + integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA== 11563 11572 11564 - expo-manifests@~1.0.8: 11565 - version "1.0.8" 11566 - resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.8.tgz#2ee1b33f974481d8cc5fc76352e0c78de5ff74d6" 11567 - integrity sha512-nA5PwU2uiUd+2nkDWf9e71AuFAtbrb330g/ecvuu52bmaXtN8J8oiilc9BDvAX0gg2fbtOaZdEdjBYopt1jdlQ== 11573 + expo-manifests@~1.0.10: 11574 + version "1.0.10" 11575 + resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.10.tgz#5dfb3db1cdf6b46fee349f1d68a25edf5e087994" 11576 + integrity sha512-oxDUnURPcL4ZsOBY6X1DGWGuoZgVAFzp6PISWV7lPP2J0r8u1/ucuChBgpK7u1eLGFp6sDIPwXyEUCkI386XSQ== 11568 11577 dependencies: 11569 - "@expo/config" "~12.0.8" 11578 + "@expo/config" "~12.0.11" 11570 11579 expo-json-utils "~0.15.0" 11571 11580 11572 - expo-media-library@~18.2.0: 11573 - version "18.2.0" 11574 - resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-18.2.0.tgz#b7515e25df5951e6b579b2ca1bee934ed206fa43" 11575 - integrity sha512-aIYLIqmU8LFWrQcfZdwg9f/iWm0wC8uhZ7HiUiTnrigtxf417cVvNokX9afXpIOKBHAHRjVIbcs1nN8KZDE2Fw== 11581 + expo-media-library@~18.2.1: 11582 + version "18.2.1" 11583 + resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-18.2.1.tgz#22aac022cc3fabfc3e004e3f3f031c7b2cd355d8" 11584 + integrity sha512-dV1acx6Aseu+I5hmF61wY8UkD4vdt8d7YXHDfgNp6ZSs06qxayUxgrBsiG2eigLe54VLm3ycbFBbWi31lhfsCA== 11576 11585 11577 - expo-modules-autolinking@3.0.20: 11578 - version "3.0.20" 11579 - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.20.tgz#d29fc6d642d98649ea1f0a2a971d60152986851d" 11580 - integrity sha512-W4XFE/A2ijrqvXYrwXug+cUQl6ALYKtsrGnd+xdnoZ+yC7HZag45CJ9mXR0qfLpwXxuBu0HDFh/a+a1MD0Ppdg== 11586 + expo-modules-autolinking@3.0.23: 11587 + version "3.0.23" 11588 + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.23.tgz#1b4349476d4c75b4f2dcefe716ff28c6746aa6ed" 11589 + integrity sha512-YZnaE0G+52xftjH5nsIRaWsoVBY38SQCECclpdgLisdbRY/6Mzo7ndokjauOv3mpFmzMZACHyJNu1YSAffQwTg== 11581 11590 dependencies: 11582 11591 "@expo/spawn-async" "^1.7.2" 11583 11592 chalk "^4.1.0" ··· 11585 11594 require-from-string "^2.0.2" 11586 11595 resolve-from "^5.0.0" 11587 11596 11588 - expo-modules-core@3.0.24: 11589 - version "3.0.24" 11590 - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.24.tgz#9e078938a9c081c87d827898a723ecf9016e2635" 11591 - integrity sha512-wmL0R3WVM2WEs0UJcq/rF1FKXbSrPmXozgzhCUujrb+crkW8p7Y/qKyPBAQwdwcqipuWYaFOgO49AdQ36jmvkA== 11597 + expo-modules-core@3.0.28: 11598 + version "3.0.28" 11599 + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.28.tgz#77eca5ecd6bc6d59c4b8edf4256380f46ac4c71c" 11600 + integrity sha512-8EDpksNxnN4HXWE+yhYUYAZAWTEDRzK2VpZjPSp+UBF2LtWZicXKLOCODCvsjCkTCVVA2JKKcWtGxWiteV3ueA== 11592 11601 dependencies: 11593 11602 invariant "^2.2.4" 11594 11603 11595 - expo-notifications@~0.32.12: 11596 - version "0.32.12" 11597 - resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.12.tgz#a21415153674a1116fa488100ef482960d67e9dd" 11598 - integrity sha512-FVJ5W4rOpKvmrLJ1Sd5pxiVTV4a7ApgTlKro+E5X8M2TBbXmEVOjs09klzdalXTjlzmU/Gu8aRw9xr7Ea/gZdw== 11604 + expo-notifications@~0.32.14: 11605 + version "0.32.14" 11606 + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.14.tgz#8f2161207e81272aac2fe4f1981b2ff7c8ef5529" 11607 + integrity sha512-IRxzsd94+c1sim7R9OWdICPINmL4iwsLWcG3n6FKgzZal2ZZbBym2/m/k5yv3NQORUpytqB373WBJDZvaPCtgw== 11599 11608 dependencies: 11600 - "@expo/image-utils" "^0.8.7" 11609 + "@expo/image-utils" "^0.8.8" 11601 11610 "@ide/backoff" "^1.0.0" 11602 11611 abort-controller "^3.0.0" 11603 11612 assert "^2.0.0" 11604 11613 badgin "^1.1.5" 11605 - expo-application "~7.0.7" 11606 - expo-constants "~18.0.9" 11614 + expo-application "~7.0.8" 11615 + expo-constants "~18.0.11" 11607 11616 11608 11617 expo-pwa@0.0.127: 11609 11618 version "0.0.127" ··· 11615 11624 commander "2.20.0" 11616 11625 update-check "1.5.3" 11617 11626 11618 - expo-screen-orientation@~9.0.7: 11619 - version "9.0.7" 11620 - resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.7.tgz#27eb8c9f57af22e1917fc025d318dd9bf31e05c3" 11621 - integrity sha512-UH/XlB9eMw+I2cyHSkXhAHRAPk83WyA3k5bst7GLu14wRuWiTch9fb6I7qEJK5CN6+XelcWxlBJymys6Fr/FKA== 11627 + expo-screen-orientation@~9.0.8: 11628 + version "9.0.8" 11629 + resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.8.tgz#15b8f85bd4d183831943fc5a21e3020e17432867" 11630 + integrity sha512-qRoPi3E893o3vQHT4h1NKo51+7g2hjRSbDeg1fsSo/u2pOW5s6FCeoacLvD+xofOP33cH2MkE4ua54aWWO7Icw== 11631 + 11632 + expo-server@^1.0.5: 11633 + version "1.0.5" 11634 + resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.5.tgz#2d52002199a2af99c2c8771d0657916004345ca9" 11635 + integrity sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA== 11622 11636 11623 - expo-server@^1.0.4: 11624 - version "1.0.4" 11625 - resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.4.tgz#cb90f23272257f8cb0c9dceaade26bb169d8a3f7" 11626 - integrity sha512-IN06r3oPxFh3plSXdvBL7dx0x6k+0/g0bgxJlNISs6qL5Z+gyPuWS750dpTzOeu37KyBG0RcyO9cXUKzjYgd4A== 11637 + expo-sharing@~14.0.8: 11638 + version "14.0.8" 11639 + resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-14.0.8.tgz#cfd5fcf77ab5f64cf3d192a40a925abb316d3545" 11640 + integrity sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q== 11627 11641 11628 - expo-sharing@~14.0.7: 11629 - version "14.0.7" 11630 - resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-14.0.7.tgz#64845ea569c725a9a32705be7ef772e556134e1c" 11631 - integrity sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g== 11642 + expo-sms@^14.0.7: 11643 + version "14.0.8" 11644 + resolved "https://registry.yarnpkg.com/expo-sms/-/expo-sms-14.0.8.tgz#c1b17ab17eb9c7f7a8150e86131ff3fe3ebf7915" 11645 + integrity sha512-yNdUUP4Y+arvQByXsSzY8UFPadezxcFPH4u1Ubab2VIRNmDJZf7TIAFmDi8dAbm5CPxbzjKBEbBIkmkVKppHHA== 11632 11646 11633 - expo-splash-screen@~31.0.10: 11634 - version "31.0.10" 11635 - resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-31.0.10.tgz#e0edd9782715b82a8eed34d5ebde778b79f850b4" 11636 - integrity sha512-i6g9IK798mae4yvflstQ1HkgahIJ6exzTCTw4vEdxV0J2SwiW3Tj+CwRjf0te7Zsb+7dDQhBTmGZwdv00VER2A== 11647 + expo-splash-screen@~31.0.12: 11648 + version "31.0.12" 11649 + resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-31.0.12.tgz#917bc5ad533933fbf5788f61d3212b0aaa760a45" 11650 + integrity sha512-o466xFYh7Fld7CuBrzx5I12LONo7a4xzOSbxS+buOEObL/Wp4Xu4QhXg80ZY7puCGbJbtm7Ltjgg5olnWOU/Rg== 11637 11651 dependencies: 11638 - "@expo/prebuild-config" "^54.0.3" 11652 + "@expo/prebuild-config" "^54.0.7" 11639 11653 11640 11654 expo-structured-headers@~5.0.0: 11641 11655 version "5.0.0" 11642 11656 resolved "https://registry.yarnpkg.com/expo-structured-headers/-/expo-structured-headers-5.0.0.tgz#b3cc223a7a58964652093f088a8988316db9ed9d" 11643 11657 integrity sha512-RmrBtnSphk5REmZGV+lcdgdpxyzio5rJw8CXviHE6qH5pKQQ83fhMEcigvrkBdsn2Efw2EODp4Yxl1/fqMvOZw== 11644 11658 11645 - expo-system-ui@~6.0.8: 11646 - version "6.0.8" 11647 - resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-6.0.8.tgz#283930826719c67118722669d7e31b3a334465f5" 11648 - integrity sha512-DzJYqG2fibBSLzPDL4BybGCiilYOtnI1OWhcYFwoM4k0pnEzMBt1Vj8Z67bXglDDuz2HCQPGNtB3tQft5saKqQ== 11659 + expo-system-ui@~6.0.9: 11660 + version "6.0.9" 11661 + resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-6.0.9.tgz#09b4a4301ab25ec594ae39beb7d24197c231a30c" 11662 + integrity sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg== 11649 11663 dependencies: 11650 11664 "@react-native/normalize-colors" "0.81.5" 11651 11665 debug "^4.3.2" 11652 11666 11653 - expo-task-manager@~14.0.8: 11654 - version "14.0.8" 11655 - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-14.0.8.tgz#8cd14c638a5c2544e8dd1a438431bfa44196d378" 11656 - integrity sha512-HxhyvmulM8px+LQvqIKS85KVx2UodZf5RO+FE2ltpC4mQ5IFkX/ESqiK0grzDa4pVFLyxvs8LjuUKsfB5c39PQ== 11667 + expo-task-manager@~14.0.9: 11668 + version "14.0.9" 11669 + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-14.0.9.tgz#7e410711cf3fd0c465a191916d699c6560c93192" 11670 + integrity sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA== 11657 11671 dependencies: 11658 - unimodules-app-loader "~6.0.7" 11672 + unimodules-app-loader "~6.0.8" 11659 11673 11660 11674 expo-updates-interface@~2.0.0: 11661 11675 version "2.0.0" 11662 11676 resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749" 11663 11677 integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg== 11664 11678 11665 - expo-updates@~29.0.12: 11666 - version "29.0.12" 11667 - resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.12.tgz#cb43a20a6d32426694d414a2551f7300a4f75631" 11668 - integrity sha512-gE3bU6qi5g8Y1TtBzoeHac3utR0i1Wj1ufThh+zpDyFjFbegFm+gwvNLVCBagZUClYKk/4CKxh5ytnwZmPzH+g== 11679 + expo-updates@~29.0.14: 11680 + version "29.0.15" 11681 + resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-29.0.15.tgz#a135ed0915500f0e30f6d09a9e13318e99a9c651" 11682 + integrity sha512-6Qj+g56nnCksKKnEPQFm19dfWvYB5EggQNN3SaLbIj4LI40k/pjQwqYStEuwTU+Ow+PG0AqxIhQ3NvgVPEzLvg== 11669 11683 dependencies: 11670 11684 "@expo/code-signing-certificates" "0.0.5" 11671 - "@expo/plist" "^0.4.7" 11685 + "@expo/plist" "^0.4.8" 11672 11686 "@expo/spawn-async" "^1.7.2" 11673 11687 arg "4.1.0" 11674 11688 chalk "^4.1.2" 11675 11689 debug "^4.3.4" 11676 - expo-eas-client "~1.0.7" 11677 - expo-manifests "~1.0.8" 11690 + expo-eas-client "~1.0.8" 11691 + expo-manifests "~1.0.10" 11678 11692 expo-structured-headers "~5.0.0" 11679 11693 expo-updates-interface "~2.0.0" 11680 11694 getenv "^2.0.0" 11681 - glob "^10.4.2" 11695 + glob "^13.0.0" 11682 11696 ignore "^5.3.1" 11683 11697 resolve-from "^5.0.0" 11684 11698 11685 - expo-video@~3.0.13: 11686 - version "3.0.13" 11687 - resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-3.0.13.tgz#99944a7aa36480d2e01514e3322c275e5e87c4b3" 11688 - integrity sha512-ew7+lvQsFTED8m46oYEXq5KOWoR1BBCTKF3xdj6HG9Z0egfhiStCH++cW95xYzrhTJAquzvJ5Rv27Ld0zL/vhw== 11699 + expo-video@~3.0.15: 11700 + version "3.0.15" 11701 + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-3.0.15.tgz#38921dab5bc877572b64728acb58097716239aa7" 11702 + integrity sha512-KmxHVCtOBb1fnxXL6DpgLbEe7Qlv/vHNGTLfz0u/eY8fBC9s5cncD2BhPunEffrGvNMftBzYMYDaO86x+IYpnA== 11689 11703 11690 - expo-web-browser@~15.0.9: 11691 - version "15.0.9" 11692 - resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.9.tgz#248b8de8f901e68e89944c85a46ee40205190f59" 11693 - integrity sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg== 11704 + expo-web-browser@~15.0.10: 11705 + version "15.0.10" 11706 + resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.10.tgz#ee7fb59b4f143f262c13c020433a83444181f1a3" 11707 + integrity sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg== 11694 11708 11695 - expo@^54.0.22: 11696 - version "54.0.22" 11697 - resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.22.tgz#1615f35b2b46ca2bc9109482f1bd6e64eab30858" 11698 - integrity sha512-w8J89M9BdVwo6urwvPeV4nAUwykv9si1UHUfZvSVWQ/b2aGs0Ci/a5RZ550rdEBgJXZAapIAhdW2M28Ojw+oGg== 11709 + expo@^54.0.27: 11710 + version "54.0.27" 11711 + resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.27.tgz#29973c37a0c42d87b5ff5ce0960756908132b133" 11712 + integrity sha512-50BcJs8eqGwRiMUoWwphkRGYtKFS2bBnemxLzy0lrGVA1E6F4Q7L5h3WT6w1ehEZybtOVkfJu4Z6GWo2IJcpEA== 11699 11713 dependencies: 11700 11714 "@babel/runtime" "^7.20.0" 11701 - "@expo/cli" "54.0.15" 11702 - "@expo/config" "~12.0.10" 11703 - "@expo/config-plugins" "~54.0.2" 11704 - "@expo/devtools" "0.1.7" 11705 - "@expo/fingerprint" "0.15.3" 11715 + "@expo/cli" "54.0.18" 11716 + "@expo/config" "~12.0.11" 11717 + "@expo/config-plugins" "~54.0.3" 11718 + "@expo/devtools" "0.1.8" 11719 + "@expo/fingerprint" "0.15.4" 11706 11720 "@expo/metro" "~54.1.0" 11707 - "@expo/metro-config" "54.0.8" 11721 + "@expo/metro-config" "54.0.10" 11708 11722 "@expo/vector-icons" "^15.0.3" 11709 11723 "@ungap/structured-clone" "^1.3.0" 11710 - babel-preset-expo "~54.0.6" 11711 - expo-asset "~12.0.9" 11712 - expo-constants "~18.0.10" 11713 - expo-file-system "~19.0.17" 11714 - expo-font "~14.0.9" 11715 - expo-keep-awake "~15.0.7" 11716 - expo-modules-autolinking "3.0.20" 11717 - expo-modules-core "3.0.24" 11724 + babel-preset-expo "~54.0.8" 11725 + expo-asset "~12.0.11" 11726 + expo-constants "~18.0.11" 11727 + expo-file-system "~19.0.20" 11728 + expo-font "~14.0.10" 11729 + expo-keep-awake "~15.0.8" 11730 + expo-modules-autolinking "3.0.23" 11731 + expo-modules-core "3.0.28" 11718 11732 pretty-format "^29.7.0" 11719 11733 react-refresh "^0.14.2" 11720 11734 whatwg-url-without-unicode "8.0.0-3" ··· 12249 12263 resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" 12250 12264 integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== 12251 12265 12252 - get-port@^3.2.0: 12253 - version "3.2.0" 12254 - resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc" 12255 - integrity sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg== 12256 - 12257 12266 get-port@^5.1.1: 12258 12267 version "5.1.1" 12259 12268 resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" ··· 12355 12364 minipass "^7.1.2" 12356 12365 path-scurry "^2.0.0" 12357 12366 12358 - glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: 12367 + glob@^7.0.3, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: 12359 12368 version "7.2.3" 12360 12369 resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" 12361 12370 integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== ··· 13709 13718 jest-mock "^29.7.0" 13710 13719 jest-util "^29.7.0" 13711 13720 13712 - jest-expo@^54.0.14: 13721 + jest-expo@~54.0.14: 13713 13722 version "54.0.14" 13714 13723 resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-54.0.14.tgz#4756a42b3ccc172c437c7d1913fa7b631b1e792a" 13715 13724 integrity sha512-94EgBAfmP+TVbMzNaVh2c4vhrZe9isZJEUIONiVn6wK+DgI+UUbOr7BnlMFtd2M/5s0eawK3yC3SZurhCFXiyg== ··· 14351 14360 prelude-ls "^1.2.1" 14352 14361 type-check "~0.4.0" 14353 14362 14363 + libphonenumber-js@^1.12.31: 14364 + version "1.12.31" 14365 + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.31.tgz#3cdb45641c6b77228dd1238f3d810c3bb5d91199" 14366 + integrity sha512-Z3IhgVgrqO1S5xPYM3K5XwbkDasU67/Vys4heW+lfSBALcUZjeIIzI8zCLifY+OCzSq+fpDdywMDa7z+4srJPQ== 14367 + 14354 14368 lighthouse-logger@^1.0.0: 14355 14369 version "1.4.2" 14356 14370 resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz#aef90f9e97cd81db367c7634292ee22079280aaa" ··· 15163 15177 resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" 15164 15178 integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== 15165 15179 15166 - minizlib@^3.0.1: 15167 - version "3.0.2" 15168 - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.2.tgz#f33d638eb279f664439aa38dc5f91607468cb574" 15169 - integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== 15180 + minizlib@^3.1.0: 15181 + version "3.1.0" 15182 + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" 15183 + integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== 15170 15184 dependencies: 15171 15185 minipass "^7.1.2" 15172 15186 ··· 15175 15189 resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" 15176 15190 integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== 15177 15191 15178 - mkdirp@^0.5.1: 15179 - version "0.5.6" 15180 - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" 15181 - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== 15182 - dependencies: 15183 - minimist "^1.2.6" 15184 - 15185 15192 mkdirp@^1.0.4: 15186 15193 version "1.0.4" 15187 15194 resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" 15188 15195 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== 15189 - 15190 - mkdirp@^3.0.1: 15191 - version "3.0.1" 15192 - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" 15193 - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== 15194 15196 15195 15197 moo@^0.5.1: 15196 15198 version "0.5.2" ··· 15839 15841 no-case "^3.0.4" 15840 15842 tslib "^2.0.3" 15841 15843 15842 - password-prompt@^1.0.4: 15843 - version "1.1.3" 15844 - resolved "https://registry.yarnpkg.com/password-prompt/-/password-prompt-1.1.3.tgz#05e539f4e7ca4d6c865d479313f10eb9db63ee5f" 15845 - integrity sha512-HkrjG2aJlvF0t2BMH0e2LB/EHf3Lcq3fNMzy4GYHcQblAvOl+QQji1Lx7WRBMqpVK8p+KR7bCg7oqAMXtdgqyw== 15846 - dependencies: 15847 - ansi-escapes "^4.3.2" 15848 - cross-spawn "^7.0.3" 15849 - 15850 15844 patch-package@^8.0.1: 15851 15845 version "8.0.1" 15852 15846 resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60" ··· 17740 17734 resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" 17741 17735 integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== 17742 17736 17743 - rimraf@^2.6.2, rimraf@^2.6.3: 17737 + rimraf@^2.6.3: 17744 17738 version "2.7.1" 17745 17739 resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" 17746 17740 integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== ··· 18878 18872 tinyglobby "^0.2.11" 18879 18873 ts-interface-checker "^0.1.9" 18880 18874 18881 - sudo-prompt@^8.2.0: 18882 - version "8.2.5" 18883 - resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-8.2.5.tgz#cc5ef3769a134bb94b24a631cc09628d4d53603e" 18884 - integrity sha512-rlBo3HU/1zAJUrkY6jNxDOC9eVYliG6nS4JA8u8KAshITd07tafMc/Br7xQwCSseXwJ2iCcHCE8SNWX3q8Z+kw== 18885 - 18886 18875 supports-color@^5.3.0: 18887 18876 version "5.5.0" 18888 18877 resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" ··· 18974 18963 inherits "^2.0.3" 18975 18964 readable-stream "^3.1.1" 18976 18965 18977 - tar@^7.4.3: 18978 - version "7.4.3" 18979 - resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" 18980 - integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== 18966 + tar@^7.5.2: 18967 + version "7.5.2" 18968 + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc" 18969 + integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg== 18981 18970 dependencies: 18982 18971 "@isaacs/fs-minipass" "^4.0.0" 18983 18972 chownr "^3.0.0" 18984 18973 minipass "^7.1.2" 18985 - minizlib "^3.0.1" 18986 - mkdirp "^3.0.1" 18974 + minizlib "^3.1.0" 18987 18975 yallist "^5.0.0" 18988 18976 18989 18977 temp-dir@~2.0.0: ··· 19528 19516 resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz#090128182bcc710327a1b7e4af4f5834444eaa61" 19529 19517 integrity sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg== 19530 19518 19531 - unimodules-app-loader@~6.0.7: 19532 - version "6.0.7" 19533 - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.7.tgz#d88db74075815bcdc088c6c6823a2b08394a1225" 19534 - integrity sha512-23iwxmh6/y54PRGJt/xjsOpPK8vlfusBisi3yaVSK22pxg5DmiL/+IHCtbb/crHC+gqdItcy1OoRsZQHfNSBaw== 19519 + unimodules-app-loader@~6.0.8: 19520 + version "6.0.8" 19521 + resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz#81c868b726e24b7e37d708fe0117e1869c721cdb" 19522 + integrity sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA== 19535 19523 19536 19524 unique-string@~2.0.0: 19537 19525 version "2.0.0" ··· 20224 20212 resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" 20225 20213 integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== 20226 20214 20227 - ws@^8.18.3: 20228 - version "8.18.3" 20229 - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" 20230 - integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== 20231 - 20232 20215 xcode@^3.0.1: 20233 20216 version "3.0.1" 20234 20217 resolved "https://registry.yarnpkg.com/xcode/-/xcode-3.0.1.tgz#3efb62aac641ab2c702458f9a0302696146aa53c" ··· 20381 20364 css-what "^6.1.0" 20382 20365 entities "^5.0.0" 20383 20366 20384 - zod-to-json-schema@^3.24.6: 20385 - version "3.24.6" 20386 - resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d" 20387 - integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg== 20388 - 20389 20367 zod-validation-error@^3.0.3: 20390 20368 version "3.3.0" 20391 20369 resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af" 20392 20370 integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw== 20393 20371 20394 - zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.22.4, zod@^3.23.8, zod@^3.25.76: 20372 + zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.22.4, zod@^3.23.8: 20395 20373 version "3.23.8" 20396 20374 resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" 20397 20375 integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==