mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {useState} from 'react'
2import {LayoutAnimation, Pressable, View} from 'react-native'
3import {Linking} from 'react-native'
4import {useReducedMotion} from 'react-native-reanimated'
5import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
6import {msg, t, Trans} from '@lingui/macro'
7import {useLingui} from '@lingui/react'
8import {useNavigation} from '@react-navigation/native'
9import {type NativeStackScreenProps} from '@react-navigation/native-stack'
10
11import {useActorStatus} from '#/lib/actor-status'
12import {IS_INTERNAL} from '#/lib/app-info'
13import {HELP_DESK_URL} from '#/lib/constants'
14import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
15import {
16 type CommonNavigatorParams,
17 type NavigationProp,
18} from '#/lib/routes/types'
19import {useGate} from '#/lib/statsig/statsig'
20import {sanitizeDisplayName} from '#/lib/strings/display-names'
21import {sanitizeHandle} from '#/lib/strings/handles'
22import {useProfileShadow} from '#/state/cache/profile-shadow'
23import * as persisted from '#/state/persisted'
24import {clearStorage} from '#/state/persisted'
25import {useModerationOpts} from '#/state/preferences/moderation-opts'
26import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
27import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
28import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
29import {useOnboardingDispatch} from '#/state/shell'
30import {useLoggedOutViewControls} from '#/state/shell/logged-out'
31import {useCloseAllActiveElements} from '#/state/util'
32import * as Toast from '#/view/com/util/Toast'
33import {UserAvatar} from '#/view/com/util/UserAvatar'
34import * as SettingsList from '#/screens/Settings/components/SettingsList'
35import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
36import {AvatarStackWithFetch} from '#/components/AvatarStack'
37import {useDialogControl} from '#/components/Dialog'
38import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
39import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
40import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell'
41import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
42import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
43import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion'
44import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
45import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
46import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
47import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
48import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
49import {
50 Person_Stroke2_Corner2_Rounded as PersonIcon,
51 PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon,
52 PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon,
53 PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
54} from '#/components/icons/Person'
55import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
56import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
57import * as Layout from '#/components/Layout'
58import {Loader} from '#/components/Loader'
59import * as Menu from '#/components/Menu'
60import * as Prompt from '#/components/Prompt'
61import {Text} from '#/components/Typography'
62import {useFullVerificationState} from '#/components/verification'
63import {
64 shouldShowVerificationCheckButton,
65 VerificationCheckButton,
66} from '#/components/verification/VerificationCheckButton'
67
68type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
69export function SettingsScreen({}: Props) {
70 const {_} = useLingui()
71 const reducedMotion = useReducedMotion()
72 const {logoutEveryAccount} = useSessionApi()
73 const {accounts, currentAccount} = useSession()
74 const switchAccountControl = useDialogControl()
75 const signOutPromptControl = Prompt.usePromptControl()
76 const {data: profile} = useProfileQuery({did: currentAccount?.did})
77 const {data: otherProfiles} = useProfilesQuery({
78 handles: accounts
79 .filter(acc => acc.did !== currentAccount?.did)
80 .map(acc => acc.handle),
81 })
82 const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
83 const [showAccounts, setShowAccounts] = useState(false)
84 const [showDevOptions, setShowDevOptions] = useState(false)
85 const gate = useGate()
86
87 return (
88 <Layout.Screen>
89 <Layout.Header.Outer>
90 <Layout.Header.BackButton />
91 <Layout.Header.Content>
92 <Layout.Header.TitleText>
93 <Trans>Settings</Trans>
94 </Layout.Header.TitleText>
95 </Layout.Header.Content>
96 <Layout.Header.Slot />
97 </Layout.Header.Outer>
98 <Layout.Content>
99 <SettingsList.Container>
100 <View
101 style={[
102 a.px_xl,
103 a.pt_md,
104 a.pb_md,
105 a.w_full,
106 a.gap_2xs,
107 a.align_center,
108 {minHeight: 160},
109 ]}>
110 {profile && <ProfilePreview profile={profile} />}
111 </View>
112 {accounts.length > 1 ? (
113 <>
114 <SettingsList.PressableItem
115 label={_(msg`Switch account`)}
116 accessibilityHint={_(
117 msg`Shows other accounts you can switch to`,
118 )}
119 onPress={() => {
120 if (!reducedMotion) {
121 LayoutAnimation.configureNext(
122 LayoutAnimation.Presets.easeInEaseOut,
123 )
124 }
125 setShowAccounts(s => !s)
126 }}>
127 <SettingsList.ItemIcon icon={PersonGroupIcon} />
128 <SettingsList.ItemText>
129 <Trans>Switch account</Trans>
130 </SettingsList.ItemText>
131 {showAccounts ? (
132 <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
133 ) : (
134 <AvatarStackWithFetch
135 profiles={accounts
136 .map(acc => acc.did)
137 .filter(did => did !== currentAccount?.did)
138 .slice(0, 5)}
139 />
140 )}
141 </SettingsList.PressableItem>
142 {showAccounts && (
143 <>
144 <SettingsList.Divider />
145 {accounts
146 .filter(acc => acc.did !== currentAccount?.did)
147 .map(account => (
148 <AccountRow
149 key={account.did}
150 account={account}
151 profile={otherProfiles?.profiles?.find(
152 p => p.did === account.did,
153 )}
154 pendingDid={pendingDid}
155 onPressSwitchAccount={onPressSwitchAccount}
156 />
157 ))}
158 <AddAccountRow />
159 </>
160 )}
161 </>
162 ) : (
163 <AddAccountRow />
164 )}
165 <SettingsList.Divider />
166 <SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}>
167 <SettingsList.ItemIcon icon={PersonIcon} />
168 <SettingsList.ItemText>
169 <Trans>Account</Trans>
170 </SettingsList.ItemText>
171 </SettingsList.LinkItem>
172 <SettingsList.LinkItem
173 to="/settings/privacy-and-security"
174 label={_(msg`Privacy and security`)}>
175 <SettingsList.ItemIcon icon={LockIcon} />
176 <SettingsList.ItemText>
177 <Trans>Privacy and security</Trans>
178 </SettingsList.ItemText>
179 </SettingsList.LinkItem>
180 <SettingsList.LinkItem to="/moderation" label={_(msg`Moderation`)}>
181 <SettingsList.ItemIcon icon={HandIcon} />
182 <SettingsList.ItemText>
183 <Trans>Moderation</Trans>
184 </SettingsList.ItemText>
185 </SettingsList.LinkItem>
186 {gate('reengagement_features') && (
187 <SettingsList.LinkItem
188 to="/settings/notifications"
189 label={_(msg`Notifications`)}>
190 <SettingsList.ItemIcon icon={NotificationIcon} />
191 <SettingsList.ItemText>
192 <Trans>Notifications</Trans>
193 </SettingsList.ItemText>
194 </SettingsList.LinkItem>
195 )}
196 <SettingsList.LinkItem
197 to="/settings/content-and-media"
198 label={_(msg`Content and media`)}>
199 <SettingsList.ItemIcon icon={WindowIcon} />
200 <SettingsList.ItemText>
201 <Trans>Content and media</Trans>
202 </SettingsList.ItemText>
203 </SettingsList.LinkItem>
204 <SettingsList.LinkItem
205 to="/settings/appearance"
206 label={_(msg`Appearance`)}>
207 <SettingsList.ItemIcon icon={PaintRollerIcon} />
208 <SettingsList.ItemText>
209 <Trans>Appearance</Trans>
210 </SettingsList.ItemText>
211 </SettingsList.LinkItem>
212 <SettingsList.LinkItem
213 to="/settings/accessibility"
214 label={_(msg`Accessibility`)}>
215 <SettingsList.ItemIcon icon={AccessibilityIcon} />
216 <SettingsList.ItemText>
217 <Trans>Accessibility</Trans>
218 </SettingsList.ItemText>
219 </SettingsList.LinkItem>
220 <SettingsList.LinkItem
221 to="/settings/language"
222 label={_(msg`Languages`)}>
223 <SettingsList.ItemIcon icon={EarthIcon} />
224 <SettingsList.ItemText>
225 <Trans>Languages</Trans>
226 </SettingsList.ItemText>
227 </SettingsList.LinkItem>
228 <SettingsList.PressableItem
229 onPress={() => Linking.openURL(HELP_DESK_URL)}
230 label={_(msg`Help`)}
231 accessibilityHint={_(msg`Opens helpdesk in browser`)}>
232 <SettingsList.ItemIcon icon={CircleQuestionIcon} />
233 <SettingsList.ItemText>
234 <Trans>Help</Trans>
235 </SettingsList.ItemText>
236 <SettingsList.Chevron />
237 </SettingsList.PressableItem>
238 <SettingsList.LinkItem to="/settings/about" label={_(msg`About`)}>
239 <SettingsList.ItemIcon icon={BubbleInfoIcon} />
240 <SettingsList.ItemText>
241 <Trans>About</Trans>
242 </SettingsList.ItemText>
243 </SettingsList.LinkItem>
244 <SettingsList.Divider />
245 <SettingsList.PressableItem
246 destructive
247 onPress={() => signOutPromptControl.open()}
248 label={_(msg`Sign out`)}>
249 <SettingsList.ItemText>
250 <Trans>Sign out</Trans>
251 </SettingsList.ItemText>
252 </SettingsList.PressableItem>
253 {IS_INTERNAL && (
254 <>
255 <SettingsList.Divider />
256 <SettingsList.PressableItem
257 onPress={() => {
258 if (!reducedMotion) {
259 LayoutAnimation.configureNext(
260 LayoutAnimation.Presets.easeInEaseOut,
261 )
262 }
263 setShowDevOptions(d => !d)
264 }}
265 label={_(msg`Developer options`)}>
266 <SettingsList.ItemIcon icon={CodeBracketsIcon} />
267 <SettingsList.ItemText>
268 <Trans>Developer options</Trans>
269 </SettingsList.ItemText>
270 </SettingsList.PressableItem>
271 {showDevOptions && <DevOptions />}
272 </>
273 )}
274 </SettingsList.Container>
275 </Layout.Content>
276
277 <Prompt.Basic
278 control={signOutPromptControl}
279 title={_(msg`Sign out?`)}
280 description={_(msg`You will be signed out of all your accounts.`)}
281 onConfirm={() => logoutEveryAccount('Settings')}
282 confirmButtonCta={_(msg`Sign out`)}
283 cancelButtonCta={_(msg`Cancel`)}
284 confirmButtonColor="negative"
285 />
286
287 <SwitchAccountDialog control={switchAccountControl} />
288 </Layout.Screen>
289 )
290}
291
292function ProfilePreview({
293 profile,
294}: {
295 profile: AppBskyActorDefs.ProfileViewDetailed
296}) {
297 const t = useTheme()
298 const {gtMobile} = useBreakpoints()
299 const shadow = useProfileShadow(profile)
300 const moderationOpts = useModerationOpts()
301 const verificationState = useFullVerificationState({
302 profile: shadow,
303 })
304 const {isActive: live} = useActorStatus(profile)
305
306 if (!moderationOpts) return null
307
308 const moderation = moderateProfile(profile, moderationOpts)
309 const displayName = sanitizeDisplayName(
310 profile.displayName || sanitizeHandle(profile.handle),
311 moderation.ui('displayName'),
312 )
313
314 return (
315 <>
316 <UserAvatar
317 size={80}
318 avatar={shadow.avatar}
319 moderation={moderation.ui('avatar')}
320 type={shadow.associated?.labeler ? 'labeler' : 'user'}
321 live={live}
322 />
323
324 <View
325 style={[
326 a.flex_row,
327 a.gap_xs,
328 a.align_center,
329 a.justify_center,
330 a.w_full,
331 ]}>
332 <Text
333 emoji
334 testID="profileHeaderDisplayName"
335 numberOfLines={1}
336 style={[
337 a.pt_sm,
338 t.atoms.text,
339 gtMobile ? a.text_4xl : a.text_3xl,
340 a.font_heavy,
341 ]}>
342 {displayName}
343 </Text>
344 {shouldShowVerificationCheckButton(verificationState) && (
345 <View
346 style={[
347 {
348 marginTop: platform({web: 8, ios: 8, android: 10}),
349 },
350 ]}>
351 <VerificationCheckButton profile={shadow} size="lg" />
352 </View>
353 )}
354 </View>
355 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
356 {sanitizeHandle(profile.handle, '@')}
357 </Text>
358 </>
359 )
360}
361
362function DevOptions() {
363 const {_} = useLingui()
364 const onboardingDispatch = useOnboardingDispatch()
365 const navigation = useNavigation<NavigationProp>()
366 const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
367
368 const resetOnboarding = async () => {
369 navigation.navigate('Home')
370 onboardingDispatch({type: 'start'})
371 Toast.show(_(msg`Onboarding reset`))
372 }
373
374 const clearAllStorage = async () => {
375 await clearStorage()
376 Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
377 }
378
379 const onPressUnsnoozeReminder = () => {
380 const lastEmailConfirm = new Date()
381 // wind back 3 days
382 lastEmailConfirm.setDate(lastEmailConfirm.getDate() - 3)
383 persisted.write('reminders', {
384 ...persisted.get('reminders'),
385 lastEmailConfirm: lastEmailConfirm.toISOString(),
386 })
387 Toast.show(t`You probably want to restart the app now.`)
388 }
389
390 return (
391 <>
392 <SettingsList.PressableItem
393 onPress={() => navigation.navigate('Log')}
394 label={_(msg`Open system log`)}>
395 <SettingsList.ItemText>
396 <Trans>System log</Trans>
397 </SettingsList.ItemText>
398 </SettingsList.PressableItem>
399 <SettingsList.PressableItem
400 onPress={() => navigation.navigate('Debug')}
401 label={_(msg`Open storybook page`)}>
402 <SettingsList.ItemText>
403 <Trans>Storybook</Trans>
404 </SettingsList.ItemText>
405 </SettingsList.PressableItem>
406 <SettingsList.PressableItem
407 onPress={() => navigation.navigate('DebugMod')}
408 label={_(msg`Open moderation debug page`)}>
409 <SettingsList.ItemText>
410 <Trans>Debug Moderation</Trans>
411 </SettingsList.ItemText>
412 </SettingsList.PressableItem>
413 <SettingsList.PressableItem
414 onPress={() => deleteChatDeclarationRecord()}
415 label={_(msg`Open storybook page`)}>
416 <SettingsList.ItemText>
417 <Trans>Delete chat declaration record</Trans>
418 </SettingsList.ItemText>
419 </SettingsList.PressableItem>
420 <SettingsList.PressableItem
421 onPress={() => resetOnboarding()}
422 label={_(msg`Reset onboarding state`)}>
423 <SettingsList.ItemText>
424 <Trans>Reset onboarding state</Trans>
425 </SettingsList.ItemText>
426 </SettingsList.PressableItem>
427 <SettingsList.PressableItem
428 onPress={onPressUnsnoozeReminder}
429 label={_(msg`Unsnooze email reminder`)}>
430 <SettingsList.ItemText>
431 <Trans>Unsnooze email reminder</Trans>
432 </SettingsList.ItemText>
433 </SettingsList.PressableItem>
434 <SettingsList.PressableItem
435 onPress={() => clearAllStorage()}
436 label={_(msg`Clear all storage data`)}>
437 <SettingsList.ItemText>
438 <Trans>Clear all storage data (restart after this)</Trans>
439 </SettingsList.ItemText>
440 </SettingsList.PressableItem>
441 </>
442 )
443}
444
445function AddAccountRow() {
446 const {_} = useLingui()
447 const {setShowLoggedOut} = useLoggedOutViewControls()
448 const closeEverything = useCloseAllActiveElements()
449
450 const onAddAnotherAccount = () => {
451 setShowLoggedOut(true)
452 closeEverything()
453 }
454
455 return (
456 <SettingsList.PressableItem
457 onPress={onAddAnotherAccount}
458 label={_(msg`Add another account`)}>
459 <SettingsList.ItemIcon icon={PersonPlusIcon} />
460 <SettingsList.ItemText>
461 <Trans>Add another account</Trans>
462 </SettingsList.ItemText>
463 </SettingsList.PressableItem>
464 )
465}
466
467function AccountRow({
468 profile,
469 account,
470 pendingDid,
471 onPressSwitchAccount,
472}: {
473 profile?: AppBskyActorDefs.ProfileViewDetailed
474 account: SessionAccount
475 pendingDid: string | null
476 onPressSwitchAccount: (
477 account: SessionAccount,
478 logContext: 'Settings',
479 ) => void
480}) {
481 const {_} = useLingui()
482 const t = useTheme()
483
484 const moderationOpts = useModerationOpts()
485 const removePromptControl = Prompt.usePromptControl()
486 const {removeAccount} = useSessionApi()
487 const {isActive: live} = useActorStatus(profile)
488
489 const onSwitchAccount = () => {
490 if (pendingDid) return
491 onPressSwitchAccount(account, 'Settings')
492 }
493
494 return (
495 <View style={[a.relative]}>
496 <SettingsList.PressableItem
497 onPress={onSwitchAccount}
498 label={_(msg`Switch account`)}>
499 {moderationOpts && profile ? (
500 <UserAvatar
501 size={28}
502 avatar={profile.avatar}
503 moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
504 type={profile.associated?.labeler ? 'labeler' : 'user'}
505 live={live}
506 hideLiveBadge
507 />
508 ) : (
509 <View style={[{width: 28}]} />
510 )}
511 <SettingsList.ItemText>
512 {sanitizeHandle(account.handle, '@')}
513 </SettingsList.ItemText>
514 {pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />}
515 </SettingsList.PressableItem>
516 {!pendingDid && (
517 <Menu.Root>
518 <Menu.Trigger label={_(msg`Account options`)}>
519 {({props, state}) => (
520 <Pressable
521 {...props}
522 style={[
523 a.absolute,
524 {top: 10, right: tokens.space.lg},
525 a.p_xs,
526 a.rounded_full,
527 (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
528 ]}>
529 <DotsHorizontal size="md" style={t.atoms.text} />
530 </Pressable>
531 )}
532 </Menu.Trigger>
533 <Menu.Outer showCancel>
534 <Menu.Item
535 label={_(msg`Remove account`)}
536 onPress={() => removePromptControl.open()}>
537 <Menu.ItemText>
538 <Trans>Remove account</Trans>
539 </Menu.ItemText>
540 <Menu.ItemIcon icon={PersonXIcon} />
541 </Menu.Item>
542 </Menu.Outer>
543 </Menu.Root>
544 )}
545
546 <Prompt.Basic
547 control={removePromptControl}
548 title={_(msg`Remove from quick access?`)}
549 description={_(
550 msg`This will remove @${account.handle} from the quick access list.`,
551 )}
552 onConfirm={() => {
553 removeAccount(account)
554 Toast.show(_(msg`Account removed from quick access`))
555 }}
556 confirmButtonCta={_(msg`Remove`)}
557 confirmButtonColor="negative"
558 />
559 </View>
560 )
561}