Bluesky app fork with some witchin' additions 💫

[APP-1684] Some contact import tweaks (#9555)

* Handle download link

* Improve NUX geo gating from #9549

* Fix alignment of phone code select

* Show full name

* Add gate to nux banner

* Add gate to settings screen

* Invert gate check in settings, whoops

authored by

Eric Bailey and committed by
GitHub
da87515d 348e7fa3

+138 -60
+2 -1
src/components/InternationalPhoneCodeSelect.tsx
··· 1 import {Fragment, useMemo} from 'react' 2 import {Image} from 'expo-image' 3 import {msg} from '@lingui/macro' 4 import {useLingui} from '@lingui/react' ··· 113 /> 114 ) 115 } 116 - return unicodeFlag + ' ' 117 }
··· 1 import {Fragment, useMemo} from 'react' 2 + import {Text as RNText} from 'react-native' 3 import {Image} from 'expo-image' 4 import {msg} from '@lingui/macro' 5 import {useLingui} from '@lingui/react' ··· 114 /> 115 ) 116 } 117 + return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText> 118 }
+7 -3
src/components/contacts/FindContactsBannerNUX.tsx
··· 6 import {useLingui} from '@lingui/react' 7 8 import {HITSLOP_10} from '#/lib/constants' 9 import {logger} from '#/logger' 10 import {isWeb} from '#/platform/detection' 11 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 20 const t = useTheme() 21 const {_} = useLingui() 22 const {visible, close} = useInternalState() 23 - const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 24 25 - if (!visible || !isFeatureEnabled) return null 26 27 return ( 28 <View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}> ··· 88 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 89 const {mutate: save, variables} = useSaveNux() 90 const hidden = !!variables 91 92 const visible = useMemo(() => { 93 if (isWeb) return false 94 if (hidden) return false 95 if (nux && nux.completed) return false 96 return true 97 - }, [hidden, nux]) 98 99 const close = () => { 100 save({
··· 6 import {useLingui} from '@lingui/react' 7 8 import {HITSLOP_10} from '#/lib/constants' 9 + import {useGate} from '#/lib/statsig/statsig' 10 import {logger} from '#/logger' 11 import {isWeb} from '#/platform/detection' 12 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 21 const t = useTheme() 22 const {_} = useLingui() 23 const {visible, close} = useInternalState() 24 25 + if (!visible) return null 26 27 return ( 28 <View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}> ··· 88 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 89 const {mutate: save, variables} = useSaveNux() 90 const hidden = !!variables 91 + const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 92 + const gate = useGate() 93 94 const visible = useMemo(() => { 95 if (isWeb) return false 96 if (hidden) return false 97 if (nux && nux.completed) return false 98 + if (!isFeatureEnabled) return false 99 + if (gate('disable_settings_find_contacts')) return false 100 return true 101 + }, [hidden, nux, isFeatureEnabled, gate]) 102 103 const close = () => { 104 save({
+10 -8
src/components/contacts/country-allowlist.ts
··· 18 'IT', 19 ] satisfies CountryCode[] as string[] 20 21 - export function isFindContactsFeatureEnabled(countryCode: string): boolean { 22 return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes( 23 countryCode.toUpperCase(), 24 ) ··· 26 27 export function useIsFindContactsFeatureEnabledBasedOnGeolocation() { 28 const location = useGeolocation() 29 - 30 - if (IS_DEV) return true 31 - 32 - // they can try, by they'll need a phone number 33 - // from one of the allowlisted countries 34 - if (!location.countryCode) return true 35 - 36 return isFindContactsFeatureEnabled(location.countryCode) 37 }
··· 18 'IT', 19 ] satisfies CountryCode[] as string[] 20 21 + export function isFindContactsFeatureEnabled(countryCode?: string): boolean { 22 + if (IS_DEV) return true 23 + 24 + /* 25 + * This should never happen unless geolocation fails entirely. In that 26 + * case, let the user try, since it should work as long as they have a 27 + * phone number from one of the allow-listed countries. 28 + */ 29 + if (!countryCode) return true 30 + 31 return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes( 32 countryCode.toUpperCase(), 33 ) ··· 35 36 export function useIsFindContactsFeatureEnabledBasedOnGeolocation() { 37 const location = useGeolocation() 38 return isFindContactsFeatureEnabled(location.countryCode) 39 }
+2 -4
src/components/contacts/screens/ViewMatches.tsx
··· 104 match => !state.dismissedMatches.includes(match.profile.did), 105 ) 106 107 - console.log(matches) 108 - 109 const followableDids = matches.map(match => match.profile.did) 110 const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) 111 ··· 449 const contactName = useMemo(() => { 450 if (!contact) return null 451 452 - const name = contact.firstName ?? contact.lastName ?? contact.name 453 if (name) return _(msg`Your contact ${name}`) 454 const phone = 455 contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] ··· 520 const {_} = useLingui() 521 const {currentAccount} = useSession() 522 523 - const name = contact.firstName ?? contact.lastName ?? contact.name 524 const phone = 525 contact.phoneNumbers?.find(phone => phone.isPrimary) ?? 526 contact.phoneNumbers?.[0]
··· 104 match => !state.dismissedMatches.includes(match.profile.did), 105 ) 106 107 const followableDids = matches.map(match => match.profile.did) 108 const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) 109 ··· 447 const contactName = useMemo(() => { 448 if (!contact) return null 449 450 + const name = contact.name ?? contact.firstName ?? contact.lastName 451 if (name) return _(msg`Your contact ${name}`) 452 const phone = 453 contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] ··· 518 const {_} = useLingui() 519 const {currentAccount} = useSession() 520 521 + const name = contact.name ?? contact.firstName ?? contact.lastName 522 const phone = 523 contact.phoneNumbers?.find(phone => phone.isPrimary) ?? 524 contact.phoneNumbers?.[0]
+19 -12
src/components/dialogs/nuxs/FindContactsAnnouncement.tsx
··· 6 import {useLingui} from '@lingui/react' 7 8 import {logger} from '#/logger' 9 - import {isWeb} from '#/platform/detection' 10 import {atoms as a, useTheme, web} from '#/alf' 11 import {Button, ButtonText} from '#/components/Button' 12 - import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 13 import * as Dialog from '#/components/Dialog' 14 import {useNuxDialogContext} from '#/components/dialogs/nuxs' 15 import {Text} from '#/components/Typography' 16 import {navigate} from '#/Navigation' 17 18 - export function FindContactsAnnouncement() { 19 - const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 20 21 - if (!isFeatureEnabled) { 22 - return null 23 - } 24 - 25 - return <Inner /> 26 - } 27 - 28 - function Inner() { 29 const t = useTheme() 30 const {_} = useLingui() 31 const nuxDialogs = useNuxDialogContext()
··· 6 import {useLingui} from '@lingui/react' 7 8 import {logger} from '#/logger' 9 + import {isNative, isWeb} from '#/platform/detection' 10 import {atoms as a, useTheme, web} from '#/alf' 11 import {Button, ButtonText} from '#/components/Button' 12 + import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' 13 import * as Dialog from '#/components/Dialog' 14 import {useNuxDialogContext} from '#/components/dialogs/nuxs' 15 + import { 16 + createIsEnabledCheck, 17 + isExistingUserAsOf, 18 + } from '#/components/dialogs/nuxs/utils' 19 import {Text} from '#/components/Typography' 20 + import {IS_E2E} from '#/env' 21 import {navigate} from '#/Navigation' 22 23 + export const enabled = createIsEnabledCheck(props => { 24 + return ( 25 + !IS_E2E && 26 + isNative && 27 + isExistingUserAsOf( 28 + '2025-12-16T00:00:00.000Z', 29 + props.currentProfile.createdAt, 30 + ) && 31 + isFindContactsFeatureEnabled(props.geolocation.countryCode) 32 + ) 33 + }) 34 35 + export function FindContactsAnnouncement() { 36 const t = useTheme() 37 const {_} = useLingui() 38 const nuxDialogs = useNuxDialogContext()
+17 -21
src/components/dialogs/nuxs/index.tsx
··· 10 11 import {useGate} from '#/lib/statsig/statsig' 12 import {logger} from '#/logger' 13 - import {isNative} from '#/platform/detection' 14 import {STALE} from '#/state/queries' 15 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' 16 import { ··· 20 import {useProfileQuery} from '#/state/queries/profile' 21 import {type SessionAccount, useSession} from '#/state/session' 22 import {useOnboardingState} from '#/state/shell' 23 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 24 - import {ENV} from '#/env' 25 - /* 26 - * NUXs 27 - */ 28 - import {FindContactsAnnouncement} from './FindContactsAnnouncement' 29 - import {isExistingUserAsOf} from './utils' 30 31 type Context = { 32 activeNux: Nux | undefined ··· 35 36 const queuedNuxs: { 37 id: Nux 38 - enabled?: (props: { 39 - gate: ReturnType<typeof useGate> 40 - currentAccount: SessionAccount 41 - currentProfile: AppBskyActorDefs.ProfileViewDetailed 42 - preferences: UsePreferencesQueryResponse 43 - }) => boolean 44 }[] = [ 45 { 46 id: Nux.FindContactsAnnouncement, 47 - enabled: ({currentProfile}) => { 48 - return ( 49 - isNative && 50 - ENV !== 'e2e' && 51 - isExistingUserAsOf('2025-12-16T00:00:00.000Z', currentProfile.createdAt) 52 - ) 53 - }, 54 }, 55 ] 56 ··· 101 preferences: UsePreferencesQueryResponse 102 }) { 103 const gate = useGate() 104 const {nuxs} = useNuxs() 105 const [snoozed, setSnoozed] = useState(() => { 106 return isSnoozed() ··· 143 // then check gate (track exposure) 144 if ( 145 enabled && 146 - !enabled({gate, currentAccount, currentProfile, preferences}) 147 ) { 148 continue 149 } ··· 178 currentAccount, 179 currentProfile, 180 preferences, 181 ]) 182 183 const ctx = useMemo(() => {
··· 10 11 import {useGate} from '#/lib/statsig/statsig' 12 import {logger} from '#/logger' 13 import {STALE} from '#/state/queries' 14 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' 15 import { ··· 19 import {useProfileQuery} from '#/state/queries/profile' 20 import {type SessionAccount, useSession} from '#/state/session' 21 import {useOnboardingState} from '#/state/shell' 22 + import { 23 + enabled as isFindContactsAnnouncementEnabled, 24 + FindContactsAnnouncement, 25 + } from '#/components/dialogs/nuxs/FindContactsAnnouncement' 26 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 27 + import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 28 + import {useGeolocation} from '#/geolocation' 29 30 type Context = { 31 activeNux: Nux | undefined ··· 34 35 const queuedNuxs: { 36 id: Nux 37 + enabled?: (props: EnabledCheckProps) => boolean 38 }[] = [ 39 { 40 id: Nux.FindContactsAnnouncement, 41 + enabled: isFindContactsAnnouncementEnabled, 42 }, 43 ] 44 ··· 89 preferences: UsePreferencesQueryResponse 90 }) { 91 const gate = useGate() 92 + const geolocation = useGeolocation() 93 const {nuxs} = useNuxs() 94 const [snoozed, setSnoozed] = useState(() => { 95 return isSnoozed() ··· 132 // then check gate (track exposure) 133 if ( 134 enabled && 135 + !enabled({ 136 + gate, 137 + currentAccount, 138 + currentProfile, 139 + preferences, 140 + geolocation, 141 + }) 142 ) { 143 continue 144 } ··· 173 currentAccount, 174 currentProfile, 175 preferences, 176 + geolocation, 177 ]) 178 179 const ctx = useMemo(() => {
+21
src/components/dialogs/nuxs/utils.ts
··· 1 const ONE_DAY = 1000 * 60 * 60 * 24 2 3 export function isDaysOld(days: number, createdAt?: string) {
··· 1 + import {type AppBskyActorDefs} from '@atproto/api' 2 + 3 + import {type useGate} from '#/lib/statsig/statsig' 4 + import {type UsePreferencesQueryResponse} from '#/state/queries/preferences' 5 + import {type SessionAccount} from '#/state/session' 6 + import {type Geolocation} from '#/geolocation' 7 + 8 + export type EnabledCheckProps = { 9 + gate: ReturnType<typeof useGate> 10 + currentAccount: SessionAccount 11 + currentProfile: AppBskyActorDefs.ProfileViewDetailed 12 + preferences: UsePreferencesQueryResponse 13 + geolocation: Geolocation 14 + } 15 + 16 + export function createIsEnabledCheck( 17 + cb: (props: EnabledCheckProps) => boolean, 18 + ) { 19 + return cb 20 + } 21 + 22 const ONE_DAY = 1000 * 60 * 60 * 24 23 24 export function isDaysOld(days: number, createdAt?: string) {
+1
src/lib/statsig/gates.ts
··· 4 | 'debug_show_feedcontext' 5 | 'debug_subscriptions' 6 | 'disable_onboarding_find_contacts' 7 | 'explore_show_suggested_feeds' 8 | 'feed_reply_button_open_thread' 9 | 'old_postonboarding'
··· 4 | 'debug_show_feedcontext' 5 | 'debug_subscriptions' 6 | 'disable_onboarding_find_contacts' 7 + | 'disable_settings_find_contacts' 8 | 'explore_show_suggested_feeds' 9 | 'feed_reply_button_open_thread' 10 | 'old_postonboarding'
+1 -1
src/routes.ts
··· 7 > 8 9 export const router = new Router<AllNavigatableRoutes>({ 10 - Home: '/', 11 Search: '/search', 12 Feeds: '/feeds', 13 Notifications: '/notifications',
··· 7 > 8 9 export const router = new Router<AllNavigatableRoutes>({ 10 + Home: ['/', '/download'], 11 Search: '/search', 12 Feeds: '/feeds', 13 Notifications: '/notifications',
+14 -10
src/screens/Settings/Settings.tsx
··· 16 type CommonNavigatorParams, 17 type NavigationProp, 18 } from '#/lib/routes/types' 19 import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 import {sanitizeHandle} from '#/lib/strings/handles' 21 import {isIOS, isNative} from '#/platform/detection' ··· 93 const [showDevOptions, setShowDevOptions] = useState(false) 94 const findContactsEnabled = 95 useIsFindContactsFeatureEnabledBasedOnGeolocation() 96 97 return ( 98 <Layout.Screen> ··· 211 <Trans>Content and media</Trans> 212 </SettingsList.ItemText> 213 </SettingsList.LinkItem> 214 - {isNative && findContactsEnabled && ( 215 - <SettingsList.LinkItem 216 - to="/settings/find-contacts" 217 - label={_(msg`Find friends from contacts`)}> 218 - <SettingsList.ItemIcon icon={ContactsIcon} /> 219 - <SettingsList.ItemText> 220 - <Trans>Find friends from contacts</Trans> 221 - </SettingsList.ItemText> 222 - </SettingsList.LinkItem> 223 - )} 224 <SettingsList.LinkItem 225 to="/settings/appearance" 226 label={_(msg`Appearance`)}>
··· 16 type CommonNavigatorParams, 17 type NavigationProp, 18 } from '#/lib/routes/types' 19 + import {useGate} from '#/lib/statsig/statsig' 20 import {sanitizeDisplayName} from '#/lib/strings/display-names' 21 import {sanitizeHandle} from '#/lib/strings/handles' 22 import {isIOS, isNative} from '#/platform/detection' ··· 94 const [showDevOptions, setShowDevOptions] = useState(false) 95 const findContactsEnabled = 96 useIsFindContactsFeatureEnabledBasedOnGeolocation() 97 + const gate = useGate() 98 99 return ( 100 <Layout.Screen> ··· 213 <Trans>Content and media</Trans> 214 </SettingsList.ItemText> 215 </SettingsList.LinkItem> 216 + {isNative && 217 + findContactsEnabled && 218 + !gate('disable_settings_find_contacts') && ( 219 + <SettingsList.LinkItem 220 + to="/settings/find-contacts" 221 + label={_(msg`Find friends from contacts`)}> 222 + <SettingsList.ItemIcon icon={ContactsIcon} /> 223 + <SettingsList.ItemText> 224 + <Trans>Find friends from contacts</Trans> 225 + </SettingsList.ItemText> 226 + </SettingsList.LinkItem> 227 + )} 228 <SettingsList.LinkItem 229 to="/settings/appearance" 230 label={_(msg`Appearance`)}>
+44
src/view/screens/Storybook/Forms.tsx
··· 1 import React from 'react' 2 import {type TextInput, View} from 'react-native' 3 4 import {atoms as a} from '#/alf' 5 import {Button, ButtonText} from '#/components/Button' 6 import {DateField, LabelText} from '#/components/forms/DateField' ··· 9 import * as Toggle from '#/components/forms/Toggle' 10 import * as ToggleButton from '#/components/forms/ToggleButton' 11 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 12 import {H1, H3} from '#/components/Typography' 13 14 export function Forms() { ··· 22 23 const [value, setValue] = React.useState('') 24 const [date, setDate] = React.useState('2001-01-01') 25 26 const inputRef = React.useRef<TextInput>(null) 27 28 return ( 29 <View style={[a.gap_4xl, a.align_start]}> 30 <H1>Forms</H1> 31 32 <View style={[a.gap_md, a.align_start, a.w_full]}> 33 <H3>InputText</H3>
··· 1 import React from 'react' 2 import {type TextInput, View} from 'react-native' 3 4 + import {APP_LANGUAGES} from '#/lib/../locale/languages' 5 import {atoms as a} from '#/alf' 6 import {Button, ButtonText} from '#/components/Button' 7 import {DateField, LabelText} from '#/components/forms/DateField' ··· 10 import * as Toggle from '#/components/forms/Toggle' 11 import * as ToggleButton from '#/components/forms/ToggleButton' 12 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 13 + import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect' 14 + import * as Select from '#/components/Select' 15 import {H1, H3} from '#/components/Typography' 16 17 export function Forms() { ··· 25 26 const [value, setValue] = React.useState('') 27 const [date, setDate] = React.useState('2001-01-01') 28 + const [countryCode, setCountryCode] = React.useState('US') 29 + const [phoneNumber, setPhoneNumber] = React.useState('') 30 + const [lang, setLang] = React.useState('en') 31 32 const inputRef = React.useRef<TextInput>(null) 33 34 return ( 35 <View style={[a.gap_4xl, a.align_start]}> 36 <H1>Forms</H1> 37 + 38 + <Select.Root value={lang} onValueChange={setLang}> 39 + <Select.Trigger label="Select app language"> 40 + <Select.ValueText /> 41 + <Select.Icon /> 42 + </Select.Trigger> 43 + <Select.Content 44 + label="App language" 45 + renderItem={({label, value}) => ( 46 + <Select.Item value={value} label={label}> 47 + <Select.ItemIndicator /> 48 + <Select.ItemText>{label}</Select.ItemText> 49 + </Select.Item> 50 + )} 51 + items={APP_LANGUAGES.map(l => ({ 52 + label: l.name, 53 + value: l.code2, 54 + }))} 55 + /> 56 + </Select.Root> 57 + 58 + <View style={[a.flex_row, a.gap_sm, a.align_center]}> 59 + <View> 60 + <InternationalPhoneCodeSelect 61 + // @ts-ignore 62 + value={countryCode} 63 + onChange={value => setCountryCode(value)} 64 + /> 65 + </View> 66 + 67 + <View style={[a.flex_1]}> 68 + <TextField.Input 69 + label="Phone number" 70 + value={phoneNumber} 71 + onChangeText={setPhoneNumber} 72 + /> 73 + </View> 74 + </View> 75 76 <View style={[a.gap_md, a.align_start, a.w_full]}> 77 <H3>InputText</H3>