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/StepFindContactsIntro/index.web.tsx b/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx new file mode 100644 index 0000000000..bb58f202b9 --- /dev/null +++ b/src/screens/Onboarding/StepFindContactsIntro/index.web.tsx @@ -0,0 +1,3 @@ +export function StepFindContactsIntro() { + throw new Error('StepFindContactsIntro is not available on web') +} diff --git a/src/screens/Onboarding/StepFinished/index.tsx b/src/screens/Onboarding/StepFinished/index.tsx index c1cb3f02f0..abf21d51d5 100644 --- a/src/screens/Onboarding/StepFinished/index.tsx +++ b/src/screens/Onboarding/StepFinished/index.tsx @@ -1,4 +1,4 @@ -import {useCallback, useContext, useState} from 'react' +import {useCallback, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -35,28 +35,23 @@ import { useSetActiveStarterPack, } from '#/state/shell/starter-pack' import { - DescriptionText, OnboardingControls, OnboardingHeaderSlot, - TitleText, } from '#/screens/Onboarding/Layout' -import {Context, type OnboardingState} from '#/screens/Onboarding/state' +import { + type OnboardingState, + useOnboardingInternalState, +} from '#/screens/Onboarding/state' import {bulkWriteFollows} from '#/screens/Onboarding/util' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, useBreakpoints} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {IconCircle} from '#/components/IconCircle' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' -import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' -import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending' import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' import {ValuePropositionPager} from './ValuePropositionPager' export function StepFinished() { - const {state, dispatch} = useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const onboardDispatch = useOnboardingDispatch() const [saving, setSaving] = useState(false) const queryClient = useQueryClient() @@ -247,18 +242,12 @@ export function StepFinished() { gate, ]) - return state.experiments?.onboarding_value_prop ? ( + return ( - ) : ( - ) } @@ -359,90 +348,3 @@ function ValueProposition({ ) } - -function LegacyFinalStep({ - finishOnboarding, - saving, - state, -}: { - finishOnboarding: () => void - saving: boolean - state: OnboardingState -}) { - const t = useTheme() - const {_} = useLingui() - - return ( - - - - - You're ready to go! - - - We hope you have a wonderful time. Remember, Bluesky is: - - - - - - - - Public - - - - Your posts, likes, and blocks are public. Mutes are private. - - - - - - - - - Open - - - Never lose access to your followers or data. - - - - - - - - Flexible - - - Choose the algorithms that power your custom feeds. - - - - - - - - - - ) -} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 3e51409757..c859930916 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -8,25 +8,23 @@ import {logEvent} from '#/lib/statsig/statsig' import {capitalize} from '#/lib/strings/capitalize' import {logger} from '#/logger' import { - DescriptionText, OnboardingControls, - TitleText, + OnboardingDescriptionText, + OnboardingPosition, + OnboardingTitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {useOnboardingInternalState} from '#/screens/Onboarding/state' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {atoms as a} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Toggle from '#/components/forms/Toggle' -import {IconCircle} from '#/components/IconCircle' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' import {Loader} from '#/components/Loader' export function StepInterests() { const {_} = useLingui() const interestsDisplayNames = useInterestsDisplayNames() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useOnboardingInternalState() const [saving, setSaving] = React.useState(false) const [selectedInterests, setSelectedInterests] = React.useState( state.interestsStepResults.selectedInterests.map(i => i), @@ -53,17 +51,16 @@ export function StepInterests() { }, [selectedInterests, setSaving, dispatch]) return ( - - - - + + + What are your interests? - - + + We'll use this to help customize your experience. - + - + Continue - + {saving && } 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}]}> + )} + + + + ) +} + +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 && ( + + )} + + ) + } + + 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. + + + + + + ) +} 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}) { ) : ( )} 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={