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, 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 • <Trans>We never keep plain phone numbers</Trans>
241 </Text>
242 <Text style={style}>
243 • <Trans>We delete hashes after matches are made</Trans>
244 </Text>
245 <Text style={style}>
246 • <Trans>We only suggest follows if both people consent</Trans>
247 </Text>
248 <Text style={style}>
249 • <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}