Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 354 lines 11 kB view raw
1import {useContext} from 'react' 2import {Alert, View} from 'react-native' 3import {useSafeAreaInsets} from 'react-native-safe-area-context' 4import * as Contacts from 'expo-contacts' 5import type AtpAgent from '@atproto/api' 6import { 7 type AppBskyActorProfile, 8 AppBskyContactImportContacts, 9 type Un$Typed, 10} from '@atproto/api' 11import {msg, t} from '@lingui/core/macro' 12import {useLingui} from '@lingui/react' 13import {Trans} from '@lingui/react/macro' 14import {useMutation, useQueryClient} from '@tanstack/react-query' 15 16import {uploadBlob} from '#/lib/api' 17import {cleanError, isNetworkError} from '#/lib/strings/errors' 18import {logger} from '#/logger' 19import {findContactsStatusQueryKey} from '#/state/queries/find-contacts' 20import {useAgent} from '#/state/session' 21import { 22 Context as OnboardingContext, 23 type OnboardingAction, 24 type OnboardingState, 25} from '#/screens/Onboarding/state' 26import {atoms as a, ios, tokens, useGutters} from '#/alf' 27import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28import * as Layout from '#/components/Layout' 29import {Loader} from '#/components/Loader' 30import * as Toast from '#/components/Toast' 31import {Text} from '#/components/Typography' 32import {useAnalytics} from '#/analytics' 33import { 34 contactsWithPhoneNumbersOnly, 35 filterMatchedNumbers, 36 getMatchedContacts, 37 normalizeContactBook, 38} from '../contacts' 39import {constructFullPhoneNumber} from '../phone-number' 40import {type Action, type State} from '../state' 41 42const MAX_UPLOAD_COUNT = 1000 43 44export function GetContacts({ 45 state, 46 dispatch, 47 onCancel, 48 context, 49}: { 50 state: Extract<State, {step: '3: get contacts'}> 51 dispatch: React.ActionDispatch<[Action]> 52 onCancel: () => void 53 context: 'Onboarding' | 'Standalone' 54}) { 55 const {_} = useLingui() 56 const ax = useAnalytics() 57 const agent = useAgent() 58 const insets = useSafeAreaInsets() 59 const gutters = useGutters([0, 'wide']) 60 const queryClient = useQueryClient() 61 const maybeOnboardingContext = useContext(OnboardingContext) 62 63 const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({ 64 mutationFn: async (contacts: Contacts.ExistingContact[]) => { 65 /** 66 * `importContacts` triggers a notification for the people you match with, 67 * however we prevent notifications coming from users without profiles. 68 * If you're using this as the onboarding flow, we need to create a profile 69 * record before this. 70 * 71 * When you finish onboarding, we'll upsert again - bit wasteful but fine. 72 */ 73 if (context === 'Onboarding' && maybeOnboardingContext) { 74 try { 75 await createProfileRecord(agent, maybeOnboardingContext) 76 } catch (error) { 77 logger.debug('Error creating profile record:', {safeMessage: error}) 78 } 79 } 80 81 const {phoneNumbers, indexToContactId} = normalizeContactBook( 82 contacts, 83 state.phoneCountryCode, 84 constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber), 85 ) 86 87 if (phoneNumbers.length > 0) { 88 const res = await agent.app.bsky.contact.importContacts({ 89 token: state.token, 90 contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT), 91 }) 92 93 return { 94 matches: res.data.matchesAndContactIndexes, 95 indexToContactId, 96 } 97 } else { 98 return { 99 matches: [], 100 indexToContactId, 101 } 102 } 103 }, 104 onSuccess: (result, contacts) => { 105 if (context === 'Onboarding') { 106 ax.metric('onboarding:contacts:contactsShared', {}) 107 } 108 if (result.matches.length > 0) { 109 ax.metric('contacts:import:success', { 110 contactCount: contacts.length, 111 matchCount: result.matches.length, 112 entryPoint: context, 113 }) 114 } else { 115 ax.metric('contacts:import:failure', { 116 reason: 'noValidNumbers', 117 entryPoint: context, 118 }) 119 } 120 dispatch({ 121 type: 'SYNC_CONTACTS_SUCCESS', 122 payload: { 123 matches: getMatchedContacts( 124 contacts, 125 result.matches, 126 result.indexToContactId, 127 ), 128 contacts: filterMatchedNumbers( 129 contacts, 130 result.matches, 131 result.indexToContactId, 132 ), 133 }, 134 }) 135 queryClient.invalidateQueries({ 136 queryKey: findContactsStatusQueryKey, 137 }) 138 }, 139 onError: err => { 140 ax.metric('contacts:import:failure', { 141 reason: isNetworkError(err) ? 'networkError' : 'unknown', 142 entryPoint: context, 143 }) 144 if (isNetworkError(err)) { 145 Toast.show( 146 _( 147 msg`There was a problem with your internet connection, please try again`, 148 ), 149 {type: 'error'}, 150 ) 151 } else if ( 152 err instanceof AppBskyContactImportContacts.TooManyContactsError 153 ) { 154 Toast.show( 155 _( 156 msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`, 157 ), 158 {type: 'error'}, 159 ) 160 } else if ( 161 err instanceof AppBskyContactImportContacts.InvalidTokenError 162 ) { 163 Toast.show( 164 _( 165 msg`Could not upload contacts. You need to re-verify your phone number to proceed`, 166 ), 167 {type: 'error'}, 168 ) 169 } else { 170 logger.error('Error uploading contacts', {safeMessage: err}) 171 Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), { 172 type: 'error', 173 }) 174 } 175 }, 176 }) 177 178 const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({ 179 mutationFn: async () => { 180 let permissions = await Contacts.getPermissionsAsync() 181 182 if (!permissions.granted && permissions.canAskAgain) { 183 permissions = await Contacts.requestPermissionsAsync() 184 } 185 186 ax.metric('contacts:permission:request', { 187 status: permissions.granted ? 'granted' : 'denied', 188 accessLevelIOS: ios(permissions.accessPrivileges), 189 }) 190 191 if (!permissions.granted) { 192 throw new PermissionDeniedError() 193 } 194 195 const contacts = await Contacts.getContactsAsync({ 196 fields: [ 197 Contacts.Fields.FirstName, 198 Contacts.Fields.LastName, 199 Contacts.Fields.PhoneNumbers, 200 Contacts.Fields.Image, 201 ], 202 }) 203 204 return contactsWithPhoneNumbersOnly(contacts.data) 205 }, 206 onSuccess: contacts => { 207 dispatch({ 208 type: 'GET_CONTACTS_SUCCESS', 209 payload: {contacts}, 210 }) 211 uploadContacts(contacts) 212 }, 213 onError: err => { 214 if (err instanceof PermissionDeniedError) { 215 showPermissionDeniedAlert() 216 } else { 217 logger.error('Error getting contacts', {safeMessage: err}) 218 } 219 }, 220 }) 221 222 const isPending = isUploadPending || isGetContactsPending 223 224 const style = [a.text_md, a.leading_snug, a.mt_md] 225 226 return ( 227 <View style={[a.h_full]}> 228 <Layout.Content 229 contentContainerStyle={[gutters, a.flex_1, a.pt_xl]} 230 bounces={false}> 231 <Text style={[a.font_bold, a.text_3xl]}> 232 <Trans>Share your contacts to find friends</Trans> 233 </Text> 234 <Text style={style}> 235 <Trans> 236 Bluesky helps friends find each other by creating an encoded digital 237 fingerprint, called a "hash", and then looking for matching hashes. 238 </Trans> 239 </Text> 240 <Text style={style}> 241 &bull; <Trans>We never keep plain phone numbers</Trans> 242 </Text> 243 <Text style={style}> 244 &bull; <Trans>We delete hashes after matches are made</Trans> 245 </Text> 246 <Text style={style}> 247 &bull; <Trans>We only suggest follows if both people consent</Trans> 248 </Text> 249 <Text style={style}> 250 &bull; <Trans>You can always opt out and delete your data</Trans> 251 </Text> 252 <Text style={[style, a.mt_lg]}> 253 <Trans> 254 We apply the highest privacy standards, and never share or sell your 255 contact information. 256 </Trans> 257 </Text> 258 </Layout.Content> 259 <View 260 style={[ 261 gutters, 262 a.pt_xs, 263 {paddingBottom: Math.max(insets.bottom, tokens.space.xl)}, 264 a.gap_md, 265 ]}> 266 <Text style={[a.text_sm, a.pb_xs]}> 267 <Trans> 268 I consent to Bluesky using my contacts for mutual friend discovery 269 and to retain hashed data for matching until I opt out. 270 </Trans> 271 </Text> 272 <Button 273 label={_(msg`Find my friends`)} 274 size="large" 275 color="primary" 276 onPress={() => getContacts()} 277 disabled={isPending}> 278 {isUploadPending ? ( 279 <> 280 <ButtonText> 281 <Trans>Finding friends...</Trans> 282 </ButtonText> 283 <ButtonIcon icon={Loader} /> 284 </> 285 ) : ( 286 <ButtonText> 287 <Trans>Find my friends</Trans> 288 </ButtonText> 289 )} 290 </Button> 291 <Button 292 label={_(msg`Cancel`)} 293 size="large" 294 color="secondary" 295 onPress={onCancel}> 296 <ButtonText> 297 <Trans>Cancel</Trans> 298 </ButtonText> 299 </Button> 300 </View> 301 </View> 302 ) 303} 304 305class PermissionDeniedError extends Error { 306 constructor() { 307 super('Permission denied') 308 } 309} 310 311function showPermissionDeniedAlert() { 312 Alert.alert( 313 t`You've denied access to your contacts`, 314 t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`, 315 [ 316 { 317 text: t`OK`, 318 style: 'default', 319 }, 320 ], 321 ) 322} 323 324/** 325 * Copied from `#/screens/Onboarding/StepFinished/index.tsx` 326 */ 327async function createProfileRecord( 328 agent: AtpAgent, 329 onboardingContext: { 330 state: OnboardingState 331 dispatch: React.Dispatch<OnboardingAction> 332 }, 333) { 334 const profileStepResults = onboardingContext.state.profileStepResults 335 const {imageUri, imageMime} = profileStepResults 336 const blobPromise = 337 imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined 338 339 await agent.upsertProfile(async existing => { 340 let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {} 341 342 if (blobPromise) { 343 const res = await blobPromise 344 if (res.data.blob) { 345 next.avatar = res.data.blob 346 } 347 } 348 349 next.displayName = '' 350 351 next.createdAt = new Date().toISOString() 352 return next 353 }) 354}