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