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

Configure Feed

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

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