forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useState} from 'react'
2import {Alert, LayoutAnimation, Pressable, View} from 'react-native'
3import type Animated from 'react-native-reanimated'
4import {
5 useAnimatedRef,
6 useReducedMotion,
7 useScrollViewOffset,
8} from 'react-native-reanimated'
9import {setStringAsync} from 'expo-clipboard'
10import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
11import {Trans, useLingui} from '@lingui/react/macro'
12import {useNavigation} from '@react-navigation/native'
13import {type NativeStackScreenProps} from '@react-navigation/native-stack'
14
15import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
16import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
17import {
18 type CommonNavigatorParams,
19 type NavigationProp,
20} from '#/lib/routes/types'
21import {sanitizeDisplayName} from '#/lib/strings/display-names'
22import {sanitizeHandle} from '#/lib/strings/handles'
23import {useProfileShadow} from '#/state/cache/profile-shadow'
24import * as persisted from '#/state/persisted'
25import {clearStorage} from '#/state/persisted'
26import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
27import {useModerationOpts} from '#/state/preferences/moderation-opts'
28import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
29import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
30import {useAgent} from '#/state/session'
31import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
32import {pdsAgent} from '#/state/session/agent'
33import {
34 type AccountSortOption,
35 sortAccountItems,
36 useAccountSwitcherSortSettings,
37} from '#/state/session/sorting'
38import {useOnboardingDispatch} from '#/state/shell'
39import {useLoggedOutViewControls} from '#/state/shell/logged-out'
40import {useCloseAllActiveElements} from '#/state/util'
41import {UserAvatar} from '#/view/com/util/UserAvatar'
42import * as SettingsList from '#/screens/Settings/components/SettingsList'
43import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
44import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
45import {AvatarStackWithFetch} from '#/components/AvatarStack'
46import {Button, ButtonIcon, ButtonText} from '#/components/Button'
47import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/contacts/country-allowlist'
48import {useDialogControl} from '#/components/Dialog'
49import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
50import {SortableList} from '#/components/DraggableList'
51import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
52import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ReverseIcon} from '#/components/icons/ArrowRotate'
53import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell'
54import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
55import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
56import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
57import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets'
58import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts'
59import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
60import {Eclipse_Stroke2_Corner0_Rounded as EclipseIcon} from '#/components/icons/Eclipse'
61import {Filter_Stroke2_Corner0_Rounded as SortIcon} from '#/components/icons/Filter'
62import {FloppyDisk_Stroke2_Corner0_Rounded as SaveIcon} from '#/components/icons/FloppyDisk'
63import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
64import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
65import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller'
66import {
67 Person_Stroke2_Corner2_Rounded as PersonIcon,
68 PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon,
69 PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon,
70 PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
71} from '#/components/icons/Person'
72import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
73import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
74import * as Layout from '#/components/Layout'
75import {Loader} from '#/components/Loader'
76import * as Menu from '#/components/Menu'
77import {ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
78import {ProfileBadges} from '#/components/ProfileBadges'
79import * as Prompt from '#/components/Prompt'
80import * as Toast from '#/components/Toast'
81import {Text} from '#/components/Typography'
82import {useAnalytics} from '#/analytics'
83import {IS_INTERNAL, IS_IOS, IS_NATIVE} from '#/env'
84import {useActorStatus} from '#/features/liveNow'
85import {device, useStorage} from '#/storage'
86import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
87import {useDevMode} from '#/storage/hooks/dev-mode'
88import {useHiddenAccountsElsewhere} from '#/storage/hooks/hidden-accounts-elsewhere'
89
90type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
91type AccountListItem = {
92 account: SessionAccount
93 profile?: AppBskyActorDefs.ProfileViewDetailed
94}
95
96export function SettingsScreen({}: Props) {
97 const ax = useAnalytics()
98 const {t: l} = useLingui()
99 const t = useTheme()
100 const reducedMotion = useReducedMotion()
101 const scrollRef = useAnimatedRef<Animated.ScrollView>()
102 const scrollOffset = useScrollViewOffset(scrollRef)
103 const {logoutEveryAccount, reorderAccounts} = useSessionApi()
104 const {accounts, currentAccount} = useSession()
105 const switchAccountControl = useDialogControl()
106 const signOutPromptControl = Prompt.usePromptControl()
107 const {data: profile} = useProfileQuery({did: currentAccount?.did})
108 const {data: otherProfiles} = useProfilesQuery({
109 handles: accounts
110 .filter(acc => acc.did !== currentAccount?.did)
111 .map(acc => acc.did),
112 })
113 const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
114 const enableSquareButtons = useEnableSquareButtons()
115 const [showAccounts, setShowAccounts] = useState(false)
116 const [isDraggingAccounts, setIsDraggingAccounts] = useState(false)
117 const [isCustomSortEditing, setIsCustomSortEditing] = useState(false)
118 const [customAccountsDraft, setCustomAccountsDraft] = useState<
119 AccountListItem[]
120 >([])
121 const {sortBy, setSortBy, reverse, setReverse} =
122 useAccountSwitcherSortSettings()
123 const [, , hiddenDidsSet] = useHiddenAccountsElsewhere()
124 const [showDevOptions, setShowDevOptions] = useState(false)
125 const findContactsEnabled =
126 useIsFindContactsFeatureEnabledBasedOnGeolocation()
127 const allAccounts = accounts.map(account => ({
128 account,
129 profile:
130 account.did === currentAccount?.did
131 ? profile
132 : otherProfiles?.profiles?.find(p => p.did === account.did),
133 }))
134 const otherAccounts = allAccounts.filter(
135 item => item.account.did !== currentAccount?.did,
136 )
137 const displayedAccounts = isCustomSortEditing
138 ? customAccountsDraft
139 : sortAccountItems(otherAccounts, sortBy, reverse)
140
141 const onSelectAccountsSort = (nextSortBy: AccountSortOption) => {
142 if (nextSortBy === 'custom') {
143 setCustomAccountsDraft(sortAccountItems(allAccounts, sortBy, reverse))
144 setIsCustomSortEditing(true)
145 return
146 }
147 setSortBy(nextSortBy)
148 setIsCustomSortEditing(false)
149 setCustomAccountsDraft([])
150 }
151
152 const onToggleReverseAccounts = () => {
153 setReverse(!reverse)
154 if (isCustomSortEditing) {
155 setCustomAccountsDraft(prev => [...prev].reverse())
156 }
157 }
158
159 const onCancelCustomSort = () => {
160 setIsCustomSortEditing(false)
161 setCustomAccountsDraft([])
162 }
163
164 const onSaveCustomSort = () => {
165 const orderedAccounts = reverse
166 ? [...customAccountsDraft].reverse()
167 : customAccountsDraft
168 reorderAccounts(orderedAccounts.map(item => item.account))
169 setSortBy('custom')
170 setIsCustomSortEditing(false)
171 setCustomAccountsDraft([])
172 }
173
174 return (
175 <Layout.Screen>
176 <Layout.Header.Outer>
177 <Layout.Header.BackButton />
178 <Layout.Header.Content>
179 <Layout.Header.TitleText>
180 <Trans>Settings</Trans>
181 </Layout.Header.TitleText>
182 </Layout.Header.Content>
183 <Layout.Header.Slot />
184 </Layout.Header.Outer>
185 <Layout.Content ref={scrollRef} scrollEnabled={!isDraggingAccounts}>
186 <SettingsList.Container>
187 <AgeAssuranceDismissibleNotice style={[a.px_lg, a.pt_xs, a.pb_xl]} />
188
189 <View
190 style={[
191 a.px_xl,
192 a.pt_md,
193 a.pb_md,
194 a.w_full,
195 a.gap_2xs,
196 a.align_center,
197 {minHeight: 160},
198 ]}>
199 {profile && <ProfilePreview profile={profile} />}
200 </View>
201 {accounts.length > 1 ? (
202 <>
203 <View style={[a.relative]}>
204 <SettingsList.PressableItem
205 label={l`Switch account`}
206 accessibilityHint={l`Shows other accounts you can switch to`}
207 onPress={() => {
208 if (!reducedMotion) {
209 LayoutAnimation.configureNext(
210 LayoutAnimation.Presets.easeInEaseOut,
211 )
212 }
213 if (showAccounts) {
214 setIsCustomSortEditing(false)
215 setCustomAccountsDraft([])
216 }
217 setShowAccounts(s => !s)
218 }}>
219 <SettingsList.ItemIcon icon={PersonGroupIcon} />
220 <SettingsList.ItemText
221 style={[showAccounts && {paddingRight: 64}]}>
222 <Trans>Switch account</Trans>
223 </SettingsList.ItemText>
224 {showAccounts ? (
225 <SettingsList.ItemIcon icon={ChevronUpIcon} size="md" />
226 ) : (
227 <AvatarStackWithFetch
228 profiles={sortAccountItems(otherAccounts, sortBy, reverse)
229 .filter(item => !hiddenDidsSet.has(item.account.did))
230 .map(item => item.account.did)
231 .slice(0, 5)}
232 />
233 )}
234 </SettingsList.PressableItem>
235 {showAccounts && (
236 <Menu.Root>
237 <Menu.Trigger label={l`Sort accounts`}>
238 {({props, state}) => (
239 <Pressable
240 {...props}
241 style={[
242 a.absolute,
243 {top: 10, right: 48},
244 a.p_xs,
245 enableSquareButtons ? a.rounded_sm : a.rounded_full,
246 (state.hovered || state.pressed) &&
247 t.atoms.bg_contrast_25,
248 ]}>
249 <SortIcon size="md" style={t.atoms.text} />
250 </Pressable>
251 )}
252 </Menu.Trigger>
253 <Menu.Outer showCancel>
254 <Menu.Group>
255 <Menu.LabelText>
256 <Trans>Sort accounts</Trans>
257 </Menu.LabelText>
258 <Menu.Item
259 label={l`Alphabetical`}
260 onPress={() => onSelectAccountsSort('alphabetical')}>
261 <Menu.ItemRadio
262 selected={
263 (isCustomSortEditing ? 'custom' : sortBy) ===
264 'alphabetical'
265 }
266 />
267 <Menu.ItemText>
268 <Trans>Alphabetical</Trans>
269 </Menu.ItemText>
270 </Menu.Item>
271 <Menu.Item
272 label={l`By date modified`}
273 onPress={() => onSelectAccountsSort('dateModified')}>
274 <Menu.ItemRadio
275 selected={
276 (isCustomSortEditing ? 'custom' : sortBy) ===
277 'dateModified'
278 }
279 />
280 <Menu.ItemText>
281 <Trans>By date modified</Trans>
282 </Menu.ItemText>
283 </Menu.Item>
284 <Menu.Item
285 label={l`By date added`}
286 onPress={() => onSelectAccountsSort('dateAdded')}>
287 <Menu.ItemRadio
288 selected={
289 (isCustomSortEditing ? 'custom' : sortBy) ===
290 'dateAdded'
291 }
292 />
293 <Menu.ItemText>
294 <Trans>By date added</Trans>
295 </Menu.ItemText>
296 </Menu.Item>
297 <Menu.Item
298 label={l`Custom`}
299 onPress={() => onSelectAccountsSort('custom')}>
300 <Menu.ItemRadio
301 selected={
302 (isCustomSortEditing ? 'custom' : sortBy) ===
303 'custom'
304 }
305 />
306 <Menu.ItemText>
307 <Trans>Custom</Trans>
308 </Menu.ItemText>
309 </Menu.Item>
310 </Menu.Group>
311 <Menu.Divider />
312 <Menu.Item
313 label={l`Reverse order`}
314 onPress={onToggleReverseAccounts}>
315 <Menu.ItemRadio selected={reverse} />
316 <Menu.ItemText>
317 <Trans>Reverse order</Trans>
318 </Menu.ItemText>
319 <Menu.ItemIcon icon={ReverseIcon} position="right" />
320 </Menu.Item>
321 </Menu.Outer>
322 </Menu.Root>
323 )}
324 </View>
325 {showAccounts && (
326 <>
327 <SettingsList.Divider />
328 {isCustomSortEditing ? (
329 <SortableList
330 data={customAccountsDraft}
331 keyExtractor={item => item.account.did}
332 itemHeight={48}
333 scrollRef={scrollRef}
334 scrollOffset={scrollOffset}
335 onDragStart={() => setIsDraggingAccounts(true)}
336 onDragEnd={() => setIsDraggingAccounts(false)}
337 onReorder={setCustomAccountsDraft}
338 renderItem={(item, dragHandle) => (
339 <AccountRow
340 key={item.account.did}
341 account={item.account}
342 profile={item.profile}
343 pendingDid={pendingDid}
344 disableSwitching
345 dragHandle={dragHandle}
346 onPressSwitchAccount={(account, logContext) =>
347 void onPressSwitchAccount(account, logContext)
348 }
349 />
350 )}
351 />
352 ) : (
353 displayedAccounts.map(item => (
354 <AccountRow
355 key={item.account.did}
356 account={item.account}
357 profile={item.profile}
358 pendingDid={pendingDid}
359 onPressSwitchAccount={(account, logContext) =>
360 void onPressSwitchAccount(account, logContext)
361 }
362 />
363 ))
364 )}
365 {isCustomSortEditing && (
366 <View
367 style={[a.flex_row, a.gap_sm, a.px_xl, a.pt_md, a.pb_sm]}>
368 <Button
369 label={l`Cancel`}
370 onPress={onCancelCustomSort}
371 color="secondary"
372 size="small"
373 style={[a.flex_1]}>
374 <ButtonText>
375 <Trans>Cancel</Trans>
376 </ButtonText>
377 </Button>
378 <Button
379 label={l`Save changes`}
380 onPress={onSaveCustomSort}
381 color="primary"
382 size="small"
383 style={[a.flex_1]}>
384 <ButtonIcon icon={SaveIcon} />
385 <ButtonText>
386 <Trans>Save changes</Trans>
387 </ButtonText>
388 </Button>
389 </View>
390 )}
391 <AddAccountRow />
392 </>
393 )}
394 </>
395 ) : (
396 <AddAccountRow />
397 )}
398 <SettingsList.Divider />
399 <SettingsList.LinkItem to="/settings/account" label={l`Account`}>
400 <SettingsList.ItemIcon icon={PersonIcon} />
401 <SettingsList.ItemText>
402 <Trans>Account</Trans>
403 </SettingsList.ItemText>
404 </SettingsList.LinkItem>
405 <SettingsList.LinkItem
406 to="/settings/privacy-and-security"
407 label={l`Privacy and security`}>
408 <SettingsList.ItemIcon icon={LockIcon} />
409 <SettingsList.ItemText>
410 <Trans>Privacy and security</Trans>
411 </SettingsList.ItemText>
412 </SettingsList.LinkItem>
413 <SettingsList.LinkItem to="/moderation" label={l`Moderation`}>
414 <SettingsList.ItemIcon icon={HandIcon} />
415 <SettingsList.ItemText>
416 <Trans>Moderation and content filters</Trans>
417 </SettingsList.ItemText>
418 </SettingsList.LinkItem>
419 <SettingsList.LinkItem
420 to="/settings/notifications"
421 label={l`Notifications`}>
422 <SettingsList.ItemIcon icon={NotificationIcon} />
423 <SettingsList.ItemText>
424 <Trans>Notifications</Trans>
425 </SettingsList.ItemText>
426 </SettingsList.LinkItem>
427 <SettingsList.LinkItem
428 to="/settings/content-and-media"
429 label={l`Content and media`}>
430 <SettingsList.ItemIcon icon={WindowIcon} />
431 <SettingsList.ItemText>
432 <Trans>Content and media</Trans>
433 </SettingsList.ItemText>
434 </SettingsList.LinkItem>
435 {IS_NATIVE &&
436 findContactsEnabled &&
437 !ax.features.enabled(ax.features.ImportContactsSettingsDisable) && (
438 <SettingsList.LinkItem
439 to="/settings/find-contacts"
440 label={l`Find friends from contacts`}>
441 <SettingsList.ItemIcon icon={ContactsIcon} />
442 <SettingsList.ItemText>
443 <Trans>Find friends from contacts</Trans>
444 </SettingsList.ItemText>
445 </SettingsList.LinkItem>
446 )}
447 <SettingsList.LinkItem
448 to="/settings/appearance"
449 label={l`Appearance`}>
450 <SettingsList.ItemIcon icon={PaintRollerIcon} />
451 <SettingsList.ItemText>
452 <Trans>Appearance</Trans>
453 </SettingsList.ItemText>
454 </SettingsList.LinkItem>
455 <SettingsList.LinkItem
456 to="/settings/accessibility"
457 label={l`Accessibility`}>
458 <SettingsList.ItemIcon icon={AccessibilityIcon} />
459 <SettingsList.ItemText>
460 <Trans>Accessibility</Trans>
461 </SettingsList.ItemText>
462 </SettingsList.LinkItem>
463 <SettingsList.LinkItem to="/settings/language" label={l`Languages`}>
464 <SettingsList.ItemIcon icon={EarthIcon} />
465 <SettingsList.ItemText>
466 <Trans>Languages</Trans>
467 </SettingsList.ItemText>
468 </SettingsList.LinkItem>
469 <SettingsList.LinkItem to="/settings/runes" label={l`Runes`}>
470 <SettingsList.ItemIcon icon={EclipseIcon} />
471 <SettingsList.ItemText>
472 <Trans>Runes</Trans>
473 </SettingsList.ItemText>
474 </SettingsList.LinkItem>
475 <SettingsList.LinkItem to="/settings/about" label={l`About`}>
476 <SettingsList.ItemIcon icon={BubbleInfoIcon} />
477 <SettingsList.ItemText>
478 <Trans>About</Trans>
479 </SettingsList.ItemText>
480 </SettingsList.LinkItem>
481 <SettingsList.Divider />
482 <SettingsList.PressableItem
483 destructive
484 onPress={() => signOutPromptControl.open()}
485 label={l`Sign out`}>
486 <SettingsList.ItemText>
487 <Trans>Sign out</Trans>
488 </SettingsList.ItemText>
489 </SettingsList.PressableItem>
490 {IS_INTERNAL && (
491 <>
492 <SettingsList.Divider />
493 <SettingsList.PressableItem
494 onPress={() => {
495 if (!reducedMotion) {
496 LayoutAnimation.configureNext(
497 LayoutAnimation.Presets.easeInEaseOut,
498 )
499 }
500 setShowDevOptions(d => !d)
501 }}
502 label={l`Developer options`}>
503 <SettingsList.ItemIcon icon={CodeBracketsIcon} />
504 <SettingsList.ItemText>
505 <Trans>Developer options</Trans>
506 </SettingsList.ItemText>
507 </SettingsList.PressableItem>
508 {showDevOptions && <DevOptions />}
509 </>
510 )}
511 </SettingsList.Container>
512 </Layout.Content>
513
514 <Prompt.Basic
515 control={signOutPromptControl}
516 title={l`Sign out?`}
517 description={l`You will be signed out of all your accounts.`}
518 onConfirm={() => logoutEveryAccount('Settings')}
519 confirmButtonCta={l`Sign out`}
520 cancelButtonCta={l`Cancel`}
521 confirmButtonColor="negative"
522 />
523
524 <SwitchAccountDialog control={switchAccountControl} />
525 </Layout.Screen>
526 )
527}
528
529function ProfilePreview({
530 profile,
531}: {
532 profile: AppBskyActorDefs.ProfileViewDetailed
533}) {
534 const t = useTheme()
535 const {gtMobile} = useBreakpoints()
536 const shadow = useProfileShadow(profile)
537 const moderationOpts = useModerationOpts()
538 const {isActive: live} = useActorStatus(profile)
539
540 if (!moderationOpts) return null
541
542 const moderation = moderateProfile(profile, moderationOpts)
543 const displayName = sanitizeDisplayName(
544 profile.displayName || sanitizeHandle(profile.handle),
545 moderation.ui('displayName'),
546 )
547
548 return (
549 <>
550 <UserAvatar
551 size={80}
552 avatar={shadow.avatar}
553 moderation={moderation.ui('avatar')}
554 type={shadow.associated?.labeler ? 'labeler' : 'user'}
555 live={live}
556 />
557
558 <View
559 style={[
560 a.flex_row,
561 a.gap_xs,
562 a.align_center,
563 a.justify_center,
564 a.w_full,
565 ]}>
566 <Text
567 emoji
568 testID="profileHeaderDisplayName"
569 numberOfLines={1}
570 style={[
571 a.pt_sm,
572 t.atoms.text,
573 gtMobile ? a.text_4xl : a.text_3xl,
574 a.font_bold,
575 ]}>
576 {displayName}
577 </Text>
578 <ProfileBadges
579 profile={shadow}
580 size="xl"
581 interactive
582 style={[
583 {
584 marginTop: platform({web: 8, ios: 8, android: 10}),
585 },
586 ]}
587 />
588 </View>
589 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}>
590 {sanitizeHandle(profile.handle, '@')}
591 </Text>
592 </>
593 )
594}
595
596function DevOptions() {
597 const {t: l} = useLingui()
598 const agent = useAgent()
599 const [override, setOverride] = useStorage(device, [
600 'policyUpdateDebugOverride',
601 ])
602 const onboardingDispatch = useOnboardingDispatch()
603 const navigation = useNavigation<NavigationProp>()
604 const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
605 const {
606 tryApplyUpdate,
607 revertToEmbedded,
608 isCurrentlyRunningPullRequestDeployment,
609 currentChannel,
610 } = useApplyPullRequestOTAUpdate()
611 const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
612
613 const resetOnboarding = () => {
614 navigation.navigate('Home')
615 onboardingDispatch({type: 'start'})
616 Toast.show(l`Onboarding reset`)
617 }
618
619 const clearAllStorage = async () => {
620 await clearStorage()
621 Toast.show(l`Storage cleared, you need to restart the app now.`)
622 }
623
624 const onPressUnsnoozeReminder = () => {
625 const lastEmailConfirm = new Date()
626 // wind back 3 days
627 lastEmailConfirm.setDate(lastEmailConfirm.getDate() - 3)
628 void persisted.write('reminders', {
629 ...persisted.get('reminders'),
630 lastEmailConfirm: lastEmailConfirm.toISOString(),
631 })
632 Toast.show(l`You probably want to restart the app now.`)
633 }
634
635 const onPressActySubsUnNudge = () => {
636 setActyNotifNudged(false)
637 }
638
639 const onPressApplyOta = () => {
640 Alert.prompt(
641 'Apply OTA',
642 'Enter the channel for the OTA you wish to apply.',
643 [
644 {
645 style: 'cancel',
646 text: 'Cancel',
647 },
648 {
649 style: 'default',
650 text: 'Apply',
651 onPress: (channel?: string) => {
652 void tryApplyUpdate(channel ?? '')
653 },
654 },
655 ],
656 'plain-text',
657 isCurrentlyRunningPullRequestDeployment
658 ? currentChannel
659 : 'pull-request-',
660 )
661 }
662
663 return (
664 <>
665 <SettingsList.PressableItem
666 onPress={() => navigation.navigate('Log')}
667 label={l`Open system log`}>
668 <SettingsList.ItemText>
669 <Trans>System log</Trans>
670 </SettingsList.ItemText>
671 </SettingsList.PressableItem>
672 <SettingsList.PressableItem
673 onPress={() => navigation.navigate('Debug')}
674 label={l`Open storybook page`}>
675 <SettingsList.ItemText>
676 <Trans>Storybook</Trans>
677 </SettingsList.ItemText>
678 </SettingsList.PressableItem>
679 <SettingsList.PressableItem
680 onPress={() => navigation.navigate('DebugMod')}
681 label={l`Open moderation debug page`}>
682 <SettingsList.ItemText>
683 <Trans>Debug Moderation</Trans>
684 </SettingsList.ItemText>
685 </SettingsList.PressableItem>
686 <SettingsList.PressableItem
687 onPress={() => deleteChatDeclarationRecord()}
688 label={l`Open storybook page`}>
689 <SettingsList.ItemText>
690 <Trans>Delete chat declaration record</Trans>
691 </SettingsList.ItemText>
692 </SettingsList.PressableItem>
693 <SettingsList.PressableItem
694 onPress={() => void resetOnboarding()}
695 label={l`Reset onboarding state`}>
696 <SettingsList.ItemText>
697 <Trans>Reset onboarding state</Trans>
698 </SettingsList.ItemText>
699 </SettingsList.PressableItem>
700 <SettingsList.PressableItem
701 onPress={onPressUnsnoozeReminder}
702 label={l`Unsnooze email reminder`}>
703 <SettingsList.ItemText>
704 <Trans>Unsnooze email reminder</Trans>
705 </SettingsList.ItemText>
706 </SettingsList.PressableItem>
707 {actyNotifNudged && (
708 <SettingsList.PressableItem
709 onPress={onPressActySubsUnNudge}
710 label={l`Reset activity subscription nudge`}>
711 <SettingsList.ItemText>
712 <Trans>Reset activity subscription nudge</Trans>
713 </SettingsList.ItemText>
714 </SettingsList.PressableItem>
715 )}
716 <SettingsList.PressableItem
717 onPress={() => void clearAllStorage()}
718 label={l`Clear all storage data`}>
719 <SettingsList.ItemText>
720 <Trans>Clear all storage data (restart after this)</Trans>
721 </SettingsList.ItemText>
722 </SettingsList.PressableItem>
723 {IS_IOS ? (
724 <SettingsList.PressableItem
725 onPress={onPressApplyOta}
726 label={l`Apply Pull Request`}>
727 <SettingsList.ItemText>
728 <Trans>Apply Pull Request</Trans>
729 </SettingsList.ItemText>
730 </SettingsList.PressableItem>
731 ) : null}
732 {IS_NATIVE && isCurrentlyRunningPullRequestDeployment ? (
733 <SettingsList.PressableItem
734 onPress={() => void revertToEmbedded()}
735 label={l`Unapply Pull Request`}>
736 <SettingsList.ItemText>
737 <Trans>Unapply Pull Request {currentChannel}</Trans>
738 </SettingsList.ItemText>
739 </SettingsList.PressableItem>
740 ) : null}
741 <SettingsList.Divider />
742 <View style={[a.p_xl, a.gap_md]}>
743 <Text style={[a.text_lg, a.font_semi_bold]}>
744 PolicyUpdate202508 Debug
745 </Text>
746
747 <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_md]}>
748 <Button
749 onPress={() => {
750 setOverride(!override)
751 }}
752 label="Toggle"
753 color={override ? 'primary' : 'secondary'}
754 size="small"
755 style={[a.flex_1]}>
756 <ButtonText>
757 {override ? 'Disable debug mode' : 'Enable debug mode'}
758 </ButtonText>
759 </Button>
760
761 <Button
762 onPress={() => {
763 device.set([PolicyUpdate202508], false)
764 void pdsAgent(agent).bskyAppRemoveNuxs([PolicyUpdate202508])
765 Toast.show(`Done`, {
766 type: 'info',
767 })
768 }}
769 label="Reset policy update nux"
770 color="secondary"
771 size="small"
772 disabled={!override}>
773 <ButtonText>Reset state</ButtonText>
774 </Button>
775 </View>
776 </View>
777 <SettingsList.Divider />
778 </>
779 )
780}
781
782function AddAccountRow() {
783 const {t: l} = useLingui()
784 const {setShowLoggedOut} = useLoggedOutViewControls()
785 const closeEverything = useCloseAllActiveElements()
786
787 const onAddAnotherAccount = () => {
788 setShowLoggedOut(true)
789 closeEverything()
790 }
791
792 return (
793 <SettingsList.PressableItem
794 onPress={onAddAnotherAccount}
795 label={l`Add another account`}>
796 <SettingsList.ItemIcon icon={PersonPlusIcon} />
797 <SettingsList.ItemText>
798 <Trans>Add another account</Trans>
799 </SettingsList.ItemText>
800 </SettingsList.PressableItem>
801 )
802}
803
804function AccountRow({
805 profile,
806 account,
807 pendingDid,
808 disableSwitching,
809 dragHandle,
810 onPressSwitchAccount,
811}: {
812 profile?: AppBskyActorDefs.ProfileViewDetailed
813 account: SessionAccount
814 pendingDid: string | null
815 disableSwitching?: boolean
816 dragHandle?: React.ReactNode
817 onPressSwitchAccount: (
818 account: SessionAccount,
819 logContext: 'Settings',
820 ) => void
821}) {
822 const {t: l} = useLingui()
823 const t = useTheme()
824
825 const moderationOpts = useModerationOpts()
826 const removePromptControl = Prompt.usePromptControl()
827 const {removeAccount} = useSessionApi()
828 const {isActive: live} = useActorStatus(profile)
829 const [devModeEnabled] = useDevMode()
830 const [hiddenAccountsElsewhere, setHiddenAccountsElsewhere, hiddenDidsSet] =
831 useHiddenAccountsElsewhere()
832
833 const enableSquareButtons = useEnableSquareButtons()
834 const isHiddenElsewhere = hiddenDidsSet.has(account.did)
835
836 const onSwitchAccount = () => {
837 if (pendingDid || disableSwitching) return
838 onPressSwitchAccount(account, 'Settings')
839 }
840
841 const onToggleHideElsewhere = () => {
842 setHiddenAccountsElsewhere(
843 isHiddenElsewhere
844 ? hiddenAccountsElsewhere.filter(did => did !== account.did)
845 : [...hiddenAccountsElsewhere, account.did],
846 )
847 Toast.show(
848 isHiddenElsewhere
849 ? l`Account will show in other switchers again`
850 : l`Account hidden from other switchers`,
851 )
852 }
853
854 const onCopyDid = () => {
855 void setStringAsync(account.did)
856 Toast.show(l`DID copied to clipboard`)
857 }
858
859 return (
860 <View style={[a.relative, t.atoms.bg]}>
861 <SettingsList.PressableItem
862 onPress={onSwitchAccount}
863 label={l`Switch account`}
864 disabled={Boolean(disableSwitching)}
865 contentContainerStyle={[
866 {
867 minHeight: 48,
868 },
869 ]}>
870 {moderationOpts && profile ? (
871 <UserAvatar
872 size={28}
873 avatar={profile.avatar}
874 moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
875 type={profile.associated?.labeler ? 'labeler' : 'user'}
876 live={live}
877 hideLiveBadge
878 />
879 ) : (
880 <View style={[{width: 28}]} />
881 )}
882 <SettingsList.ItemText
883 numberOfLines={1}
884 style={[
885 a.leading_snug,
886 a.self_center,
887 !disableSwitching && a.pr_2xl,
888 ]}>
889 {sanitizeHandle(account.handle, '@')}
890 </SettingsList.ItemText>
891 {pendingDid === account.did && <SettingsList.ItemIcon icon={Loader} />}
892 {disableSwitching ? (
893 <View style={[a.self_center]}>{dragHandle}</View>
894 ) : null}
895 </SettingsList.PressableItem>
896 {!pendingDid && !disableSwitching && (
897 <Menu.Root>
898 <Menu.Trigger label={l`Account options`}>
899 {({props, state}) => (
900 <Pressable
901 {...props}
902 style={[
903 a.absolute,
904 {top: 12, right: tokens.space.lg},
905 a.p_xs,
906 enableSquareButtons ? a.rounded_sm : a.rounded_full,
907 (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
908 ]}>
909 <DotsHorizontal size="md" style={t.atoms.text} />
910 </Pressable>
911 )}
912 </Menu.Trigger>
913 <Menu.Outer showCancel>
914 <Menu.Item
915 label={
916 isHiddenElsewhere ? l`Hidden elsewhere` : l`Hide elsewhere`
917 }
918 onPress={onToggleHideElsewhere}>
919 <Menu.ItemText>
920 {isHiddenElsewhere ? (
921 <Trans>Hidden elsewhere</Trans>
922 ) : (
923 <Trans>Hide elsewhere</Trans>
924 )}
925 </Menu.ItemText>
926 <Menu.ItemRadio selected={isHiddenElsewhere} />
927 </Menu.Item>
928 {devModeEnabled ? (
929 <Menu.Item label={l`Copy DID`} onPress={onCopyDid}>
930 <Menu.ItemText>
931 <Trans>Copy DID</Trans>
932 </Menu.ItemText>
933 <Menu.ItemIcon icon={ClipboardIcon} />
934 </Menu.Item>
935 ) : null}
936 <Menu.Divider />
937 <Menu.Item
938 label={l`Remove account`}
939 onPress={() => removePromptControl.open()}>
940 <Menu.ItemText>
941 <Trans>Remove account</Trans>
942 </Menu.ItemText>
943 <Menu.ItemIcon icon={PersonXIcon} />
944 </Menu.Item>
945 </Menu.Outer>
946 </Menu.Root>
947 )}
948
949 <Prompt.Basic
950 control={removePromptControl}
951 title={l`Remove from quick access?`}
952 description={l`This will remove @${account.handle} from the quick access list.`}
953 onConfirm={() => {
954 removeAccount(account)
955 Toast.show(l`Account removed from quick access`)
956 }}
957 confirmButtonCta={l`Remove`}
958 confirmButtonColor="negative"
959 />
960 </View>
961 )
962}