forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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 • <Trans>We never keep plain phone numbers</Trans>
242 </Text>
243 <Text style={style}>
244 • <Trans>We delete hashes after matches are made</Trans>
245 </Text>
246 <Text style={style}>
247 • <Trans>We only suggest follows if both people consent</Trans>
248 </Text>
249 <Text style={style}>
250 • <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}