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

Changed files
+138 -60
src
+2 -1
src/components/InternationalPhoneCodeSelect.tsx
··· 1 1 import {Fragment, useMemo} from 'react' 2 + import {Text as RNText} from 'react-native' 2 3 import {Image} from 'expo-image' 3 4 import {msg} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' ··· 113 114 /> 114 115 ) 115 116 } 116 - return unicodeFlag + ' ' 117 + return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText> 117 118 }
+7 -3
src/components/contacts/FindContactsBannerNUX.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {HITSLOP_10} from '#/lib/constants' 9 + import {useGate} from '#/lib/statsig/statsig' 9 10 import {logger} from '#/logger' 10 11 import {isWeb} from '#/platform/detection' 11 12 import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs' ··· 20 21 const t = useTheme() 21 22 const {_} = useLingui() 22 23 const {visible, close} = useInternalState() 23 - const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 24 24 25 - if (!visible || !isFeatureEnabled) return null 25 + if (!visible) return null 26 26 27 27 return ( 28 28 <View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}> ··· 88 88 const {nux} = useNux(Nux.FindContactsDismissibleBanner) 89 89 const {mutate: save, variables} = useSaveNux() 90 90 const hidden = !!variables 91 + const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 92 + const gate = useGate() 91 93 92 94 const visible = useMemo(() => { 93 95 if (isWeb) return false 94 96 if (hidden) return false 95 97 if (nux && nux.completed) return false 98 + if (!isFeatureEnabled) return false 99 + if (gate('disable_settings_find_contacts')) return false 96 100 return true 97 - }, [hidden, nux]) 101 + }, [hidden, nux, isFeatureEnabled, gate]) 98 102 99 103 const close = () => { 100 104 save({
+10 -8
src/components/contacts/country-allowlist.ts
··· 18 18 'IT', 19 19 ] satisfies CountryCode[] as string[] 20 20 21 - export function isFindContactsFeatureEnabled(countryCode: string): boolean { 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 + 22 31 return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes( 23 32 countryCode.toUpperCase(), 24 33 ) ··· 26 35 27 36 export function useIsFindContactsFeatureEnabledBasedOnGeolocation() { 28 37 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 38 return isFindContactsFeatureEnabled(location.countryCode) 37 39 }
+2 -4
src/components/contacts/screens/ViewMatches.tsx
··· 104 104 match => !state.dismissedMatches.includes(match.profile.did), 105 105 ) 106 106 107 - console.log(matches) 108 - 109 107 const followableDids = matches.map(match => match.profile.did) 110 108 const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0) 111 109 ··· 449 447 const contactName = useMemo(() => { 450 448 if (!contact) return null 451 449 452 - const name = contact.firstName ?? contact.lastName ?? contact.name 450 + const name = contact.name ?? contact.firstName ?? contact.lastName 453 451 if (name) return _(msg`Your contact ${name}`) 454 452 const phone = 455 453 contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0] ··· 520 518 const {_} = useLingui() 521 519 const {currentAccount} = useSession() 522 520 523 - const name = contact.firstName ?? contact.lastName ?? contact.name 521 + const name = contact.name ?? contact.firstName ?? contact.lastName 524 522 const phone = 525 523 contact.phoneNumbers?.find(phone => phone.isPrimary) ?? 526 524 contact.phoneNumbers?.[0]
+19 -12
src/components/dialogs/nuxs/FindContactsAnnouncement.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {logger} from '#/logger' 9 - import {isWeb} from '#/platform/detection' 9 + import {isNative, isWeb} from '#/platform/detection' 10 10 import {atoms as a, useTheme, web} from '#/alf' 11 11 import {Button, ButtonText} from '#/components/Button' 12 - import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist' 12 + import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist' 13 13 import * as Dialog from '#/components/Dialog' 14 14 import {useNuxDialogContext} from '#/components/dialogs/nuxs' 15 + import { 16 + createIsEnabledCheck, 17 + isExistingUserAsOf, 18 + } from '#/components/dialogs/nuxs/utils' 15 19 import {Text} from '#/components/Typography' 20 + import {IS_E2E} from '#/env' 16 21 import {navigate} from '#/Navigation' 17 22 18 - export function FindContactsAnnouncement() { 19 - const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation() 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 + }) 20 34 21 - if (!isFeatureEnabled) { 22 - return null 23 - } 24 - 25 - return <Inner /> 26 - } 27 - 28 - function Inner() { 35 + export function FindContactsAnnouncement() { 29 36 const t = useTheme() 30 37 const {_} = useLingui() 31 38 const nuxDialogs = useNuxDialogContext()
+17 -21
src/components/dialogs/nuxs/index.tsx
··· 10 10 11 11 import {useGate} from '#/lib/statsig/statsig' 12 12 import {logger} from '#/logger' 13 - import {isNative} from '#/platform/detection' 14 13 import {STALE} from '#/state/queries' 15 14 import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' 16 15 import { ··· 20 19 import {useProfileQuery} from '#/state/queries/profile' 21 20 import {type SessionAccount, useSession} from '#/state/session' 22 21 import {useOnboardingState} from '#/state/shell' 22 + import { 23 + enabled as isFindContactsAnnouncementEnabled, 24 + FindContactsAnnouncement, 25 + } from '#/components/dialogs/nuxs/FindContactsAnnouncement' 23 26 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' 27 + import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils' 28 + import {useGeolocation} from '#/geolocation' 30 29 31 30 type Context = { 32 31 activeNux: Nux | undefined ··· 35 34 36 35 const queuedNuxs: { 37 36 id: Nux 38 - enabled?: (props: { 39 - gate: ReturnType<typeof useGate> 40 - currentAccount: SessionAccount 41 - currentProfile: AppBskyActorDefs.ProfileViewDetailed 42 - preferences: UsePreferencesQueryResponse 43 - }) => boolean 37 + enabled?: (props: EnabledCheckProps) => boolean 44 38 }[] = [ 45 39 { 46 40 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 - }, 41 + enabled: isFindContactsAnnouncementEnabled, 54 42 }, 55 43 ] 56 44 ··· 101 89 preferences: UsePreferencesQueryResponse 102 90 }) { 103 91 const gate = useGate() 92 + const geolocation = useGeolocation() 104 93 const {nuxs} = useNuxs() 105 94 const [snoozed, setSnoozed] = useState(() => { 106 95 return isSnoozed() ··· 143 132 // then check gate (track exposure) 144 133 if ( 145 134 enabled && 146 - !enabled({gate, currentAccount, currentProfile, preferences}) 135 + !enabled({ 136 + gate, 137 + currentAccount, 138 + currentProfile, 139 + preferences, 140 + geolocation, 141 + }) 147 142 ) { 148 143 continue 149 144 } ··· 178 173 currentAccount, 179 174 currentProfile, 180 175 preferences, 176 + geolocation, 181 177 ]) 182 178 183 179 const ctx = useMemo(() => {
+21
src/components/dialogs/nuxs/utils.ts
··· 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 + 1 22 const ONE_DAY = 1000 * 60 * 60 * 24 2 23 3 24 export function isDaysOld(days: number, createdAt?: string) {
+1
src/lib/statsig/gates.ts
··· 4 4 | 'debug_show_feedcontext' 5 5 | 'debug_subscriptions' 6 6 | 'disable_onboarding_find_contacts' 7 + | 'disable_settings_find_contacts' 7 8 | 'explore_show_suggested_feeds' 8 9 | 'feed_reply_button_open_thread' 9 10 | 'old_postonboarding'
+1 -1
src/routes.ts
··· 7 7 > 8 8 9 9 export const router = new Router<AllNavigatableRoutes>({ 10 - Home: '/', 10 + Home: ['/', '/download'], 11 11 Search: '/search', 12 12 Feeds: '/feeds', 13 13 Notifications: '/notifications',
+14 -10
src/screens/Settings/Settings.tsx
··· 16 16 type CommonNavigatorParams, 17 17 type NavigationProp, 18 18 } from '#/lib/routes/types' 19 + import {useGate} from '#/lib/statsig/statsig' 19 20 import {sanitizeDisplayName} from '#/lib/strings/display-names' 20 21 import {sanitizeHandle} from '#/lib/strings/handles' 21 22 import {isIOS, isNative} from '#/platform/detection' ··· 93 94 const [showDevOptions, setShowDevOptions] = useState(false) 94 95 const findContactsEnabled = 95 96 useIsFindContactsFeatureEnabledBasedOnGeolocation() 97 + const gate = useGate() 96 98 97 99 return ( 98 100 <Layout.Screen> ··· 211 213 <Trans>Content and media</Trans> 212 214 </SettingsList.ItemText> 213 215 </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 - )} 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 + )} 224 228 <SettingsList.LinkItem 225 229 to="/settings/appearance" 226 230 label={_(msg`Appearance`)}>
+44
src/view/screens/Storybook/Forms.tsx
··· 1 1 import React from 'react' 2 2 import {type TextInput, View} from 'react-native' 3 3 4 + import {APP_LANGUAGES} from '#/lib/../locale/languages' 4 5 import {atoms as a} from '#/alf' 5 6 import {Button, ButtonText} from '#/components/Button' 6 7 import {DateField, LabelText} from '#/components/forms/DateField' ··· 9 10 import * as Toggle from '#/components/forms/Toggle' 10 11 import * as ToggleButton from '#/components/forms/ToggleButton' 11 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' 12 15 import {H1, H3} from '#/components/Typography' 13 16 14 17 export function Forms() { ··· 22 25 23 26 const [value, setValue] = React.useState('') 24 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') 25 31 26 32 const inputRef = React.useRef<TextInput>(null) 27 33 28 34 return ( 29 35 <View style={[a.gap_4xl, a.align_start]}> 30 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> 31 75 32 76 <View style={[a.gap_md, a.align_start, a.w_full]}> 33 77 <H3>InputText</H3>