diff options
Diffstat (limited to 'src/screens/Settings')
-rw-r--r-- | src/screens/Settings/AboutSettings.tsx | 78 | ||||
-rw-r--r-- | src/screens/Settings/AccessibilitySettings.tsx | 113 | ||||
-rw-r--r-- | src/screens/Settings/AccountSettings.tsx | 180 | ||||
-rw-r--r-- | src/screens/Settings/AppearanceSettings.tsx | 165 | ||||
-rw-r--r-- | src/screens/Settings/ContentAndMediaSettings.tsx | 104 | ||||
-rw-r--r-- | src/screens/Settings/PrivacyAndSecuritySettings.tsx | 91 | ||||
-rw-r--r-- | src/screens/Settings/Settings.tsx | 282 | ||||
-rw-r--r-- | src/screens/Settings/components/Email2FAToggle.tsx | 66 | ||||
-rw-r--r-- | src/screens/Settings/components/PwiOptOut.tsx | 100 | ||||
-rw-r--r-- | src/screens/Settings/components/SettingsList.tsx | 300 |
10 files changed, 1390 insertions, 89 deletions
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx new file mode 100644 index 000000000..3c445b966 --- /dev/null +++ b/src/screens/Settings/AboutSettings.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import {Platform} from 'react-native' +import {setStringAsync} from 'expo-clipboard' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info' +import {STATUS_PAGE_URL} from '#/lib/constants' +import {CommonNavigatorParams} from '#/lib/routes/types' +import * as Toast from '#/view/com/util/Toast' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {CodeLines_Stroke2_Corner2_Rounded as CodeLinesIcon} from '#/components/icons/CodeLines' +import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' +import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' +import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'AboutSettings'> +export function AboutSettingsScreen({}: Props) { + const {_} = useLingui() + + return ( + <Layout.Screen> + <Layout.Header title={_(msg`About`)} /> + <Layout.Content> + <SettingsList.Container> + <SettingsList.LinkItem + to="https://bsky.social/about/support/tos" + label={_(msg`Terms of Service`)}> + <SettingsList.ItemIcon icon={NewspaperIcon} /> + <SettingsList.ItemText> + <Trans>Terms of Service</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="https://bsky.social/about/support/privacy-policy" + label={_(msg`Privacy Policy`)}> + <SettingsList.ItemIcon icon={NewspaperIcon} /> + <SettingsList.ItemText> + <Trans>Privacy Policy</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to={STATUS_PAGE_URL} + label={_(msg`Status Page`)}> + <SettingsList.ItemIcon icon={GlobeIcon} /> + <SettingsList.ItemText> + <Trans>Status Page</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.Divider /> + <SettingsList.LinkItem to="/sys/log" label={_(msg`System log`)}> + <SettingsList.ItemIcon icon={CodeLinesIcon} /> + <SettingsList.ItemText> + <Trans>System log</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.PressableItem + label={_(msg`Version ${appVersion}`)} + accessibilityHint={_(msg`Copy build version to clipboard`)} + onPress={() => { + setStringAsync( + `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}`, + ) + Toast.show(_(msg`Copied build version to clipboard`)) + }}> + <SettingsList.ItemIcon icon={WrenchIcon} /> + <SettingsList.ItemText> + <Trans>Version {appVersion}</Trans> + </SettingsList.ItemText> + <SettingsList.BadgeText>{bundleInfo}</SettingsList.BadgeText> + </SettingsList.PressableItem> + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/screens/Settings/AccessibilitySettings.tsx b/src/screens/Settings/AccessibilitySettings.tsx new file mode 100644 index 000000000..dfe2c14a5 --- /dev/null +++ b/src/screens/Settings/AccessibilitySettings.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {isNative} from '#/platform/detection' +import { + useHapticsDisabled, + useRequireAltTextEnabled, + useSetHapticsDisabled, + useSetRequireAltTextEnabled, +} from '#/state/preferences' +import { + useLargeAltBadgeEnabled, + useSetLargeAltBadgeEnabled, +} from '#/state/preferences/large-alt-badge' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'AccessibilitySettings' +> +export function AccessibilitySettingsScreen({}: Props) { + const {_} = useLingui() + + const requireAltTextEnabled = useRequireAltTextEnabled() + const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const hapticsDisabled = useHapticsDisabled() + const setHapticsDisabled = useSetHapticsDisabled() + const largeAltBadgeEnabled = useLargeAltBadgeEnabled() + const setLargeAltBadgeEnabled = useSetLargeAltBadgeEnabled() + + return ( + <Layout.Screen> + <Layout.Header title={_(msg`Accessibility`)} /> + <Layout.Content> + <SettingsList.Container> + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> + <SettingsList.ItemIcon icon={AccessibilityIcon} /> + <SettingsList.ItemText> + <Trans>Alt text</Trans> + </SettingsList.ItemText> + <Toggle.Item + name="require_alt_text" + label={_(msg`Require alt text before posting`)} + value={requireAltTextEnabled ?? false} + onChange={value => setRequireAltTextEnabled(value)} + style={[a.w_full]}> + <Toggle.LabelText style={[a.flex_1]}> + <Trans>Require alt text before posting</Trans> + </Toggle.LabelText> + <Toggle.Platform /> + </Toggle.Item> + <Toggle.Item + name="large_alt_badge" + label={_(msg`Display larger alt text badges`)} + value={!!largeAltBadgeEnabled} + onChange={value => setLargeAltBadgeEnabled(value)} + style={[a.w_full]}> + <Toggle.LabelText style={[a.flex_1]}> + <Trans>Display larger alt text badges</Trans> + </Toggle.LabelText> + <Toggle.Platform /> + </Toggle.Item> + </SettingsList.Group> + {isNative && ( + <> + <SettingsList.Divider /> + <SettingsList.Group contentContainerStyle={[a.gap_sm]}> + <SettingsList.ItemIcon icon={HapticIcon} /> + <SettingsList.ItemText> + <Trans>Haptics</Trans> + </SettingsList.ItemText> + <Toggle.Item + name="haptics" + label={_(msg`Disable haptic feedback`)} + value={hapticsDisabled ?? false} + onChange={value => setHapticsDisabled(value)} + style={[a.w_full]}> + <Toggle.LabelText style={[a.flex_1]}> + <Trans>Disable haptic feedback</Trans> + </Toggle.LabelText> + <Toggle.Platform /> + </Toggle.Item> + </SettingsList.Group> + </> + )} + <SettingsList.Item> + <Admonition type="info" style={[a.flex_1]}> + <Trans> + Autoplay options have moved to the{' '} + <InlineLinkText + to="/settings/content-and-media" + label={_(msg`Content and media`)}> + Content and Media settings + </InlineLinkText> + . + </Trans> + </Admonition> + </SettingsList.Item> + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx new file mode 100644 index 000000000..19101d2f4 --- /dev/null +++ b/src/screens/Settings/AccountSettings.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useQueryClient} from '@tanstack/react-query' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {useModalControls} from '#/state/modals' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' +import {BirthdayCake_Stroke2_Corner2_Rounded as BirthdayCakeIcon} from '#/components/icons/BirthdayCake' +import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' +import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' +import {Freeze_Stroke2_Corner2_Rounded as FreezeIcon} from '#/components/icons/Freeze' +import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' +import {PencilLine_Stroke2_Corner2_Rounded as PencilIcon} from '#/components/icons/Pencil' +import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash' +import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' +import * as Layout from '#/components/Layout' +import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'> +export function AccountSettingsScreen({}: Props) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const {openModal} = useModalControls() + const birthdayControl = useDialogControl() + const exportCarControl = useDialogControl() + const deactivateAccountControl = useDialogControl() + + return ( + <Layout.Screen> + <Layout.Header title={_(msg`Account`)} /> + <Layout.Content> + <SettingsList.Container> + <SettingsList.Item> + <SettingsList.ItemIcon icon={EnvelopeIcon} /> + <SettingsList.ItemText> + <Trans>Email</Trans> + </SettingsList.ItemText> + {currentAccount && ( + <> + <SettingsList.BadgeText> + {currentAccount.email || <Trans>(no email)</Trans>} + </SettingsList.BadgeText> + {currentAccount.emailConfirmed ? ( + <CheckIcon color={t.palette.positive_500} size="sm" /> + ) : ( + <SettingsList.BadgeButton + label={_(msg`Verify`)} + onPress={() => {}} + /> + )} + </> + )} + </SettingsList.Item> + <SettingsList.PressableItem + label={_(msg`Change email`)} + onPress={() => openModal({name: 'change-email'})}> + <SettingsList.ItemIcon icon={PencilIcon} /> + <SettingsList.ItemText> + <Trans>Change email</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.LinkItem + to="/settings/privacy-and-security" + label={_(msg`Protect your account`)} + style={[ + a.my_xs, + a.mx_lg, + a.rounded_md, + {backgroundColor: t.palette.primary_50}, + ]} + chevronColor={t.palette.primary_500} + hoverStyle={[{backgroundColor: t.palette.primary_100}]} + contentContainerStyle={[a.rounded_md, a.px_lg]}> + <SettingsList.ItemIcon + icon={VerifiedIcon} + color={t.palette.primary_500} + /> + <SettingsList.ItemText + style={[{color: t.palette.primary_500}, a.font_bold]}> + <Trans>Protect your account</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.Divider /> + <SettingsList.Item> + <SettingsList.ItemIcon icon={BirthdayCakeIcon} /> + <SettingsList.ItemText> + <Trans>Birthday</Trans> + </SettingsList.ItemText> + <SettingsList.BadgeButton + label={_(msg`Edit`)} + onPress={() => birthdayControl.open()} + /> + </SettingsList.Item> + <SettingsList.PressableItem + label={_(msg`Password`)} + onPress={() => openModal({name: 'change-password'})}> + <SettingsList.ItemIcon icon={LockIcon} /> + <SettingsList.ItemText> + <Trans>Password</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.PressableItem + label={_(msg`Handle`)} + onPress={() => + openModal({ + name: 'change-handle', + onChanged() { + if (currentAccount) { + // refresh my profile + queryClient.invalidateQueries({ + queryKey: RQKEY_PROFILE(currentAccount.did), + }) + } + }, + }) + }> + <SettingsList.ItemIcon icon={AtIcon} /> + <SettingsList.ItemText> + <Trans>Handle</Trans> + </SettingsList.ItemText> + {profile && ( + <SettingsList.BadgeText>@{profile.handle}</SettingsList.BadgeText> + )} + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.Divider /> + <SettingsList.PressableItem + label={_(msg`Export my data`)} + onPress={() => exportCarControl.open()}> + <SettingsList.ItemIcon icon={CarIcon} /> + <SettingsList.ItemText> + <Trans>Export my data</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.PressableItem + label={_(msg`Deactivate account`)} + onPress={() => deactivateAccountControl.open()} + destructive> + <SettingsList.ItemIcon icon={FreezeIcon} /> + <SettingsList.ItemText> + <Trans>Deactivate account</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.PressableItem + label={_(msg`Delete account`)} + onPress={() => openModal({name: 'delete-account'})} + destructive> + <SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} /> + <SettingsList.ItemText> + <Trans>Delete account</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + </SettingsList.Container> + </Layout.Content> + + <BirthDateSettingsDialog control={birthdayControl} /> + <ExportCarDialog control={exportCarControl} /> + <DeactivateAccountDialog control={deactivateAccountControl} /> + </Layout.Screen> + ) +} diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index c317c930f..d0beb7d50 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -1,18 +1,15 @@ import React, {useCallback} from 'react' -import {View} from 'react-native' import Animated, { - FadeInDown, - FadeOutDown, + FadeInUp, + FadeOutUp, LayoutAnimationConfig, + LinearTransition, } from 'react-native-reanimated' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {useSetThemePrefs, useThemePrefs} from '#/state/shell' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {ScrollView} from '#/view/com/util/Views' import {atoms as a, native, useAlf, useTheme} from '#/alf' import * as ToggleButton from '#/components/forms/ToggleButton' import {Props as SVGIconProps} from '#/components/icons/common' @@ -22,12 +19,11 @@ import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/T import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' +import * as SettingsList from './components/SettingsList' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'> export function AppearanceSettingsScreen({}: Props) { - const t = useTheme() const {_} = useLingui() - const {isTabletOrMobile} = useWebMediaQueries() const {fonts} = useAlf() const {colorMode, darkTheme} = useThemePrefs() @@ -77,66 +73,54 @@ export function AppearanceSettingsScreen({}: Props) { return ( <LayoutAnimationConfig skipExiting skipEntering> <Layout.Screen testID="preferencesThreadsScreen"> - <ScrollView - // @ts-ignore web only -prf - dataSet={{'stable-gutters': 1}} - contentContainerStyle={{paddingBottom: 75}}> - <SimpleViewHeader - showBackButton={isTabletOrMobile} - style={[t.atoms.border_contrast_medium, a.border_b]}> - <View style={a.flex_1}> - <Text style={[a.text_2xl, a.font_bold]}> - <Trans>Appearance</Trans> - </Text> - </View> - </SimpleViewHeader> - - <View style={[a.gap_3xl, a.pt_xl, a.px_xl]}> - <View style={[a.gap_lg]}> - <AppearanceToggleButtonGroup - title={_(msg`Color mode`)} - icon={PhoneIcon} - items={[ - { - label: _(msg`System`), - name: 'system', - }, - { - label: _(msg`Light`), - name: 'light', - }, - { - label: _(msg`Dark`), - name: 'dark', - }, - ]} - values={[colorMode]} - onChange={onChangeAppearance} - /> + <Layout.Header title={_(msg`Appearance`)} /> + <Layout.Content> + <SettingsList.Container> + <AppearanceToggleButtonGroup + title={_(msg`Color mode`)} + icon={PhoneIcon} + items={[ + { + label: _(msg`System`), + name: 'system', + }, + { + label: _(msg`Light`), + name: 'light', + }, + { + label: _(msg`Dark`), + name: 'dark', + }, + ]} + values={[colorMode]} + onChange={onChangeAppearance} + /> - {colorMode !== 'light' && ( - <Animated.View - entering={native(FadeInDown)} - exiting={native(FadeOutDown)}> - <AppearanceToggleButtonGroup - title={_(msg`Dark theme`)} - icon={MoonIcon} - items={[ - { - label: _(msg`Dim`), - name: 'dim', - }, - { - label: _(msg`Dark`), - name: 'dark', - }, - ]} - values={[darkTheme ?? 'dim']} - onChange={onChangeDarkTheme} - /> - </Animated.View> - )} + {colorMode !== 'light' && ( + <Animated.View + entering={native(FadeInUp)} + exiting={native(FadeOutUp)}> + <AppearanceToggleButtonGroup + title={_(msg`Dark theme`)} + icon={MoonIcon} + items={[ + { + label: _(msg`Dim`), + name: 'dim', + }, + { + label: _(msg`Dark`), + name: 'dark', + }, + ]} + values={[darkTheme ?? 'dim']} + onChange={onChangeDarkTheme} + /> + </Animated.View> + )} + <Animated.View layout={native(LinearTransition)}> <AppearanceToggleButtonGroup title={_(msg`Font`)} description={_( @@ -177,9 +161,9 @@ export function AppearanceSettingsScreen({}: Props) { values={[fonts.scale]} onChange={onChangeFontScale} /> - </View> - </View> - </ScrollView> + </Animated.View> + </SettingsList.Container> + </Layout.Content> </Layout.Screen> </LayoutAnimationConfig> ) @@ -205,29 +189,32 @@ export function AppearanceToggleButtonGroup({ }) { const t = useTheme() return ( - <View style={[a.gap_sm]}> - <View style={[a.gap_xs]}> - <View style={[a.flex_row, a.align_center, a.gap_md]}> - <Icon style={t.atoms.text} /> - <Text style={[a.text_md, a.font_bold]}>{title}</Text> - </View> + <> + <SettingsList.Group contentContainerStyle={[a.gap_sm]} iconInset={false}> + <SettingsList.ItemIcon icon={Icon} /> + <SettingsList.ItemText>{title}</SettingsList.ItemText> {description && ( <Text - style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> + style={[ + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + a.w_full, + ]}> {description} </Text> )} - </View> - <ToggleButton.Group label={title} values={values} onChange={onChange}> - {items.map(item => ( - <ToggleButton.Button - key={item.name} - label={item.label} - name={item.name}> - <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText> - </ToggleButton.Button> - ))} - </ToggleButton.Group> - </View> + <ToggleButton.Group label={title} values={values} onChange={onChange}> + {items.map(item => ( + <ToggleButton.Button + key={item.name} + label={item.label} + name={item.name}> + <ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText> + </ToggleButton.Button> + ))} + </ToggleButton.Group> + </SettingsList.Group> + </> ) } diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx new file mode 100644 index 000000000..79c8a48f3 --- /dev/null +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {isNative} from '#/platform/detection' +import {useAutoplayDisabled, useSetAutoplayDisabled} from '#/state/preferences' +import { + useInAppBrowser, + useSetInAppBrowser, +} from '#/state/preferences/in-app-browser' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import * as Toggle from '#/components/forms/Toggle' +import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' +import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons/Hashtag' +import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' +import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' +import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ContentAndMediaSettings' +> +export function ContentAndMediaSettingsScreen({}: Props) { + const {_} = useLingui() + const autoplayDisabledPref = useAutoplayDisabled() + const setAutoplayDisabledPref = useSetAutoplayDisabled() + const inAppBrowserPref = useInAppBrowser() + const setUseInAppBrowser = useSetInAppBrowser() + + return ( + <Layout.Screen> + <Layout.Header title={_(msg`Content and Media`)} /> + <Layout.Content> + <SettingsList.Container> + <SettingsList.LinkItem + to="/settings/saved-feeds" + label={_(msg`Manage saved feeds`)}> + <SettingsList.ItemIcon icon={HashtagIcon} /> + <SettingsList.ItemText> + <Trans>Manage saved feeds</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/threads" + label={_(msg`Thread preferences`)}> + <SettingsList.ItemIcon icon={BubblesIcon} /> + <SettingsList.ItemText> + <Trans>Thread preferences</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/following-feed" + label={_(msg`Following feed preferences`)}> + <SettingsList.ItemIcon icon={HomeIcon} /> + <SettingsList.ItemText> + <Trans>Following feed preferences</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/external-embeds" + label={_(msg`External media`)}> + <SettingsList.ItemIcon icon={MacintoshIcon} /> + <SettingsList.ItemText> + <Trans>External media</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.Divider /> + {isNative && ( + <Toggle.Item + name="use_in_app_browser" + label={_(msg`Use in-app browser to open links`)} + value={inAppBrowserPref ?? false} + onChange={value => setUseInAppBrowser(value)}> + <SettingsList.Item> + <SettingsList.ItemIcon icon={WindowIcon} /> + <SettingsList.ItemText> + <Trans>Use in-app browser to open links</Trans> + </SettingsList.ItemText> + <Toggle.Platform /> + </SettingsList.Item> + </Toggle.Item> + )} + <Toggle.Item + name="disable_autoplay" + label={_(msg`Disable autoplay for videos and GIFs`)} + value={autoplayDisabledPref} + onChange={value => setAutoplayDisabledPref(value)}> + <SettingsList.Item> + <SettingsList.ItemIcon icon={PlayIcon} /> + <SettingsList.ItemText> + <Trans>Disable autoplay for videos and GIFs</Trans> + </SettingsList.ItemText> + <Toggle.Platform /> + </SettingsList.Item> + </Toggle.Item> + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx new file mode 100644 index 000000000..da462c90d --- /dev/null +++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {CommonNavigatorParams} from '#/lib/routes/types' +import {useAppPasswordsQuery} from '#/state/queries/app-passwords' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import * as Admonition from '#/components/Admonition' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' +import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key' +import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {Email2FAToggle} from './components/Email2FAToggle' +import {PwiOptOut} from './components/PwiOptOut' + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'PrivacyAndSecuritySettings' +> +export function PrivacyAndSecuritySettingsScreen({}: Props) { + const {_} = useLingui() + const {data: appPasswords} = useAppPasswordsQuery() + return ( + <Layout.Screen> + <Layout.Header title={_(msg`Privacy and Security`)} /> + <Layout.Content> + <SettingsList.Container> + <SettingsList.Item> + <SettingsList.ItemIcon icon={VerifiedIcon} /> + <SettingsList.ItemText> + <Trans>Two-factor authentication (2FA)</Trans> + </SettingsList.ItemText> + <Email2FAToggle /> + </SettingsList.Item> + <SettingsList.LinkItem + to="/settings/app-passwords" + label={_(msg`App passwords`)}> + <SettingsList.ItemIcon icon={KeyIcon} /> + <SettingsList.ItemText> + <Trans>App passwords</Trans> + </SettingsList.ItemText> + {appPasswords && appPasswords.length > 0 && ( + <SettingsList.BadgeText> + {appPasswords.length} + </SettingsList.BadgeText> + )} + </SettingsList.LinkItem> + <SettingsList.Divider /> + <SettingsList.Group> + <SettingsList.ItemIcon icon={EyeSlashIcon} /> + <SettingsList.ItemText> + <Trans>Logged-out visibility</Trans> + </SettingsList.ItemText> + <PwiOptOut /> + </SettingsList.Group> + <SettingsList.Item> + <Admonition.Outer type="tip" style={[a.flex_1]}> + <Admonition.Row> + <Admonition.Icon /> + <View style={[a.flex_1, a.gap_sm]}> + <Admonition.Text> + <Trans> + Note: Bluesky is an open and public network. This setting + only limits the visibility of your content on the Bluesky + app and website, and other apps may not respect this + setting. Your content may still be shown to logged-out + users by other apps and websites. + </Trans> + </Admonition.Text> + <Admonition.Text> + <InlineLinkText + label={_( + msg`Learn more about what is public on Bluesky.`, + )} + to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"> + <Trans>Learn more about what is public on Bluesky.</Trans> + </InlineLinkText> + </Admonition.Text> + </View> + </Admonition.Row> + </Admonition.Outer> + </SettingsList.Item> + </SettingsList.Container> + </Layout.Content> + </Layout.Screen> + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx new file mode 100644 index 000000000..789ffb56f --- /dev/null +++ b/src/screens/Settings/Settings.tsx @@ -0,0 +1,282 @@ +import React from 'react' +import {View} from 'react-native' +import {Linking} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {HELP_DESK_URL} from '#/lib/constants' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' +import {useSession, useSessionApi} from '#/state/session' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName' +import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a, useTheme} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' +import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' +import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' +import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' +import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' +import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' +import { + Person_Stroke2_Corner2_Rounded as PersonIcon, + PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon, +} from '#/components/icons/Person' +import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' +import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' +import * as Layout from '#/components/Layout' +import * as Prompt from '#/components/Prompt' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> +export function SettingsScreen({}: Props) { + const {_} = useLingui() + const {logoutEveryAccount} = useSessionApi() + const {accounts, currentAccount} = useSession() + const switchAccountControl = useDialogControl() + const signOutPromptControl = Prompt.usePromptControl() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeEverything = useCloseAllActiveElements() + + const onAddAnotherAccount = () => { + setShowLoggedOut(true) + closeEverything() + } + + return ( + <Layout.Screen> + <Layout.Header title={_(msg`Settings`)} /> + <Layout.Content> + <SettingsList.Container> + <View + style={[ + a.px_xl, + a.pb_md, + a.w_full, + a.gap_2xs, + a.align_center, + {minHeight: 160}, + ]}> + {profile && <ProfilePreview profile={profile} />} + </View> + <SettingsList.PressableItem + label={ + accounts.length > 1 + ? _(msg`Switch account`) + : _(msg`Add another account`) + } + onPress={() => + accounts.length > 1 + ? switchAccountControl.open() + : onAddAnotherAccount() + }> + <SettingsList.ItemIcon icon={PersonGroupIcon} /> + <SettingsList.ItemText> + {accounts.length > 1 ? ( + <Trans>Switch account</Trans> + ) : ( + <Trans>Add another account</Trans> + )} + </SettingsList.ItemText> + {accounts.length > 1 && ( + <AvatarStack + profiles={accounts + .map(acc => acc.did) + .filter(did => did !== currentAccount?.did) + .slice(0, 5)} + /> + )} + </SettingsList.PressableItem> + <SettingsList.Divider /> + <SettingsList.LinkItem to="/settings/account" label={_(msg`Account`)}> + <SettingsList.ItemIcon icon={PersonIcon} /> + <SettingsList.ItemText> + <Trans>Account</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/privacy-and-security" + label={_(msg`Privacy and security`)}> + <SettingsList.ItemIcon icon={LockIcon} /> + <SettingsList.ItemText> + <Trans>Privacy and security</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem to="/moderation" label={_(msg`Moderation`)}> + <SettingsList.ItemIcon icon={HandIcon} /> + <SettingsList.ItemText> + <Trans>Moderation</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/content-and-media" + label={_(msg`Content and media`)}> + <SettingsList.ItemIcon icon={WindowIcon} /> + <SettingsList.ItemText> + <Trans>Content and media</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/appearance" + label={_(msg`Appearance`)}> + <SettingsList.ItemIcon icon={PaintRollerIcon} /> + <SettingsList.ItemText> + <Trans>Appearance</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/accessibility" + label={_(msg`Accessibility`)}> + <SettingsList.ItemIcon icon={AccessibilityIcon} /> + <SettingsList.ItemText> + <Trans>Accessibility</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.LinkItem + to="/settings/language" + label={_(msg`Languages`)}> + <SettingsList.ItemIcon icon={EarthIcon} /> + <SettingsList.ItemText> + <Trans>Languages</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.PressableItem + onPress={() => Linking.openURL(HELP_DESK_URL)} + label={_(msg`Help`)} + accessibilityHint={_(msg`Open helpdesk in browser`)}> + <SettingsList.ItemIcon icon={CircleQuestionIcon} /> + <SettingsList.ItemText> + <Trans>Help</Trans> + </SettingsList.ItemText> + <SettingsList.Chevron /> + </SettingsList.PressableItem> + <SettingsList.LinkItem to="/settings/about" label={_(msg`About`)}> + <SettingsList.ItemIcon icon={BubbleInfoIcon} /> + <SettingsList.ItemText> + <Trans>About</Trans> + </SettingsList.ItemText> + </SettingsList.LinkItem> + <SettingsList.Divider /> + <SettingsList.PressableItem + destructive + onPress={() => signOutPromptControl.open()} + label={_(msg`Sign out`)}> + <SettingsList.ItemText> + <Trans>Sign out</Trans> + </SettingsList.ItemText> + </SettingsList.PressableItem> + </SettingsList.Container> + </Layout.Content> + + <Prompt.Basic + control={signOutPromptControl} + title={_(msg`Sign out?`)} + description={_(msg`You will be signed out of all your accounts.`)} + onConfirm={() => logoutEveryAccount('Settings')} + confirmButtonCta={_(msg`Sign out`)} + cancelButtonCta={_(msg`Cancel`)} + confirmButtonColor="negative" + /> + + <SwitchAccountDialog control={switchAccountControl} /> + </Layout.Screen> + ) +} + +function ProfilePreview({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const shadow = useProfileShadow(profile) + const moderationOpts = useModerationOpts() + + if (!moderationOpts) return null + + const moderation = moderateProfile(profile, moderationOpts) + + return ( + <> + <UserAvatar + size={80} + avatar={shadow.avatar} + moderation={moderation.ui('avatar')} + /> + <ProfileHeaderDisplayName profile={shadow} moderation={moderation} /> + <ProfileHeaderHandle profile={shadow} /> + </> + ) +} + +const AVI_SIZE = 26 +const HALF_AVI_SIZE = AVI_SIZE / 2 + +function AvatarStack({profiles}: {profiles: string[]}) { + const {data, error} = useProfilesQuery({handles: profiles}) + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (error) { + console.error(error) + return null + } + + const isPending = !data || !moderationOpts + + const items = isPending + ? Array.from({length: profiles.length}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : data.profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + <View + style={[ + a.flex_row, + a.align_center, + a.relative, + {width: AVI_SIZE + (items.length - 1) * HALF_AVI_SIZE}, + ]}> + {items.map((item, i) => ( + <View + key={item.key} + style={[ + t.atoms.bg_contrast_25, + a.relative, + { + width: AVI_SIZE, + height: AVI_SIZE, + left: i * -HALF_AVI_SIZE, + borderWidth: 1, + borderColor: t.atoms.bg.backgroundColor, + borderRadius: 999, + zIndex: 3 - i, + }, + ]}> + {item.profile && ( + <UserAvatar + size={AVI_SIZE - 2} + avatar={item.profile.avatar} + moderation={item.moderation.ui('avatar')} + /> + )} + </View> + ))} + </View> + ) +} diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx new file mode 100644 index 000000000..d89e5f18e --- /dev/null +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useModalControls} from '#/state/modals' +import {useAgent, useSession} from '#/state/session' +import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog' +import {useDialogControl} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' +import * as SettingsList from './SettingsList' + +export function Email2FAToggle() { + const {_} = useLingui() + const {currentAccount} = useSession() + const {openModal} = useModalControls() + const disableDialogControl = useDialogControl() + const enableDialogControl = useDialogControl() + const agent = useAgent() + + const enableEmailAuthFactor = React.useCallback(async () => { + if (currentAccount?.email) { + await agent.com.atproto.server.updateEmail({ + email: currentAccount.email, + emailAuthFactor: true, + }) + await agent.resumeSession(agent.session!) + } + }, [currentAccount, agent]) + + const onToggle = React.useCallback(() => { + if (!currentAccount) { + return + } + if (currentAccount.emailAuthFactor) { + disableDialogControl.open() + } else { + if (!currentAccount.emailConfirmed) { + openModal({ + name: 'verify-email', + onSuccess: enableDialogControl.open, + }) + return + } + enableDialogControl.open() + } + }, [currentAccount, enableDialogControl, openModal, disableDialogControl]) + + return ( + <> + <DisableEmail2FADialog control={disableDialogControl} /> + <Prompt.Basic + control={enableDialogControl} + title={_(msg`Enable Email 2FA`)} + description={_(msg`Require an email code to log in to your account.`)} + onConfirm={enableEmailAuthFactor} + confirmButtonCta={_(msg`Enable`)} + /> + <SettingsList.BadgeButton + label={ + currentAccount?.emailAuthFactor ? _(msg`Disable`) : _(msg`Enable`) + } + onPress={onToggle} + /> + </> + ) +} diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx new file mode 100644 index 000000000..4339ade9b --- /dev/null +++ b/src/screens/Settings/components/PwiOptOut.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import {View} from 'react-native' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import { + useProfileQuery, + useProfileUpdateMutation, +} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +export function PwiOptOut() { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) + const updateProfile = useProfileUpdateMutation() + + const isOptedOut = + profile?.labels?.some(l => l.val === '!no-unauthenticated') || false + const canToggle = profile && !updateProfile.isPending + + const onToggleOptOut = React.useCallback(() => { + if (!profile) { + return + } + let wasAdded = false + updateProfile.mutate({ + profile, + updates: existing => { + // create labels attr if needed + existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) + ? existing.labels + : { + $type: 'com.atproto.label.defs#selfLabels', + values: [], + } + + // toggle the label + const hasLabel = existing.labels.values.some( + l => l.val === '!no-unauthenticated', + ) + if (hasLabel) { + wasAdded = false + existing.labels.values = existing.labels.values.filter( + l => l.val !== '!no-unauthenticated', + ) + } else { + wasAdded = true + existing.labels.values.push({val: '!no-unauthenticated'}) + } + + // delete if no longer needed + if (existing.labels.values.length === 0) { + delete existing.labels + } + return existing + }, + checkCommitted: res => { + const exists = !!res.data.labels?.some( + l => l.val === '!no-unauthenticated', + ) + return exists === wasAdded + }, + }) + }, [updateProfile, profile]) + + return ( + <View style={[a.flex_1, a.gap_sm]}> + <Toggle.Item + name="logged_out_visibility" + disabled={!canToggle || updateProfile.isPending} + value={isOptedOut} + onChange={onToggleOptOut} + label={_( + msg`Discourage apps from showing my account to logged-out users`, + )} + style={[a.w_full]}> + <Toggle.LabelText style={[a.flex_1]}> + <Trans> + Discourage apps from showing my account to logged-out users + </Trans> + </Toggle.LabelText> + <Toggle.Platform /> + </Toggle.Item> + + <Text style={[a.leading_snug, t.atoms.text_contrast_high]}> + <Trans> + Bluesky will not show your profile and posts to logged-out users. + Other apps may not honor this request. This does not make your account + private. + </Trans> + </Text> + </View> + ) +} diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx new file mode 100644 index 000000000..86f8040af --- /dev/null +++ b/src/screens/Settings/components/SettingsList.tsx @@ -0,0 +1,300 @@ +import React, {useContext, useMemo} from 'react' +import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native' + +import {HITSLOP_10} from '#/lib/constants' +import {atoms as a, useTheme} from '#/alf' +import * as Button from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' +import {Link, LinkProps} from '#/components/Link' +import {createPortalGroup} from '#/components/Portal' +import {Text} from '#/components/Typography' + +const ItemContext = React.createContext({ + destructive: false, + withinGroup: false, +}) + +const Portal = createPortalGroup() + +export function Container({children}: {children: React.ReactNode}) { + return <View style={[a.flex_1, a.py_lg]}>{children}</View> +} + +/** + * This uses `Portal` magic ✨ to render the icons and title correctly. ItemIcon and ItemText components + * get teleported to the top row, leaving the rest of the children in the bottom row. + */ +export function Group({ + children, + destructive = false, + iconInset = true, + style, + contentContainerStyle, +}: { + children: React.ReactNode + destructive?: boolean + iconInset?: boolean + style?: StyleProp<ViewStyle> + contentContainerStyle?: StyleProp<ViewStyle> +}) { + const context = useMemo( + () => ({destructive, withinGroup: true}), + [destructive], + ) + return ( + <View style={[a.w_full, style]}> + <Portal.Provider> + <ItemContext.Provider value={context}> + <Item style={[a.pb_2xs, {minHeight: 42}]}> + <Portal.Outlet /> + </Item> + <Item + style={[ + a.flex_col, + a.pt_2xs, + a.align_start, + a.gap_0, + contentContainerStyle, + ]} + iconInset={iconInset}> + {children} + </Item> + </ItemContext.Provider> + </Portal.Provider> + </View> + ) +} + +export function Item({ + children, + destructive, + iconInset = false, + style, +}: { + children?: React.ReactNode + destructive?: boolean + /** + * Adds left padding so that the content will be aligned with other Items that contain icons + * @default false + */ + iconInset?: boolean + style?: StyleProp<ViewStyle> +}) { + const context = useContext(ItemContext) + const childContext = useMemo(() => { + if (typeof destructive !== 'boolean') return context + return {...context, destructive} + }, [context, destructive]) + return ( + <View + style={[ + a.px_xl, + a.py_sm, + a.align_center, + a.gap_md, + a.w_full, + a.flex_row, + {minHeight: 48}, + iconInset && { + paddingLeft: + // existing padding + a.pl_xl.paddingLeft + + // icon + 28 + + // gap + a.gap_md.gap, + }, + style, + ]}> + <ItemContext.Provider value={childContext}> + {children} + </ItemContext.Provider> + </View> + ) +} + +export function LinkItem({ + children, + destructive = false, + contentContainerStyle, + chevronColor, + ...props +}: LinkProps & { + contentContainerStyle?: StyleProp<ViewStyle> + destructive?: boolean + chevronColor?: string +}) { + const t = useTheme() + + return ( + <Link color="secondary" {...props}> + {args => ( + <Item + destructive={destructive} + style={[ + (args.hovered || args.pressed) && [t.atoms.bg_contrast_25], + contentContainerStyle, + ]}> + {typeof children === 'function' ? children(args) : children} + <Chevron color={chevronColor} /> + </Item> + )} + </Link> + ) +} + +export function PressableItem({ + children, + destructive = false, + contentContainerStyle, + hoverStyle, + ...props +}: Button.ButtonProps & { + contentContainerStyle?: StyleProp<ViewStyle> + destructive?: boolean +}) { + const t = useTheme() + return ( + <Button.Button {...props}> + {args => ( + <Item + destructive={destructive} + style={[ + (args.hovered || args.pressed) && [ + t.atoms.bg_contrast_25, + hoverStyle, + ], + contentContainerStyle, + ]}> + {typeof children === 'function' ? children(args) : children} + </Item> + )} + </Button.Button> + ) +} + +export function ItemIcon({ + icon: Comp, + size = 'xl', + color: colorProp, +}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & { + color?: string +}) { + const t = useTheme() + const {destructive, withinGroup} = useContext(ItemContext) + + /* + * Copied here from icons/common.tsx so we can tweak if we need to, but + * also so that we can calculate transforms. + */ + const iconSize = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, + '2xl': 32, + }[size] + + const color = + colorProp ?? (destructive ? t.palette.negative_500 : t.atoms.text.color) + + const content = ( + <View style={[a.z_20, {width: iconSize, height: iconSize}]}> + <Comp width={iconSize} style={[{color}]} /> + </View> + ) + + if (withinGroup) { + return <Portal.Portal>{content}</Portal.Portal> + } else { + return content + } +} + +export function ItemText({ + // eslint-disable-next-line react/prop-types + style, + ...props +}: React.ComponentProps<typeof Button.ButtonText>) { + const t = useTheme() + const {destructive, withinGroup} = useContext(ItemContext) + + const content = ( + <Button.ButtonText + style={[ + a.text_md, + a.font_normal, + a.text_left, + a.flex_1, + destructive ? {color: t.palette.negative_500} : t.atoms.text, + style, + ]} + {...props} + /> + ) + + if (withinGroup) { + return <Portal.Portal>{content}</Portal.Portal> + } else { + return content + } +} + +export function Divider() { + const t = useTheme() + return ( + <View + style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]} + /> + ) +} + +export function Chevron({color: colorProp}: {color?: string}) { + const {destructive} = useContext(ItemContext) + const t = useTheme() + const color = + colorProp ?? (destructive ? t.palette.negative_500 : t.palette.contrast_500) + return <ItemIcon icon={ChevronRightIcon} size="md" color={color} /> +} + +export function BadgeText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <Text + style={[ + t.atoms.text_contrast_low, + a.text_md, + a.text_right, + a.leading_snug, + ]} + numberOfLines={1}> + {children} + </Text> + ) +} + +export function BadgeButton({ + label, + onPress, +}: { + label: string + onPress: (evt: GestureResponderEvent) => void +}) { + const t = useTheme() + return ( + <Button.Button label={label} onPress={onPress} hitSlop={HITSLOP_10}> + {({pressed}) => ( + <Button.ButtonText + style={[ + a.text_md, + a.font_normal, + a.text_right, + {color: pressed ? t.palette.contrast_300 : t.palette.primary_500}, + ]}> + {label} + </Button.ButtonText> + )} + </Button.Button> + ) +} |