diff --git a/assets/icons/bubbles_stroke2_corner2_rounded.svg b/assets/icons/bubbles_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..f09fcaa33c --- /dev/null +++ b/assets/icons/bubbles_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/car_stroke2_corner2_rounded.svg b/assets/icons/car_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..ca29cefd1c --- /dev/null +++ b/assets/icons/car_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/circleQuestion_stroke2_corner2_rounded.svg b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg index a534f98716..c025a14509 100644 --- a/assets/icons/circleQuestion_stroke2_corner2_rounded.svg +++ b/assets/icons/circleQuestion_stroke2_corner2_rounded.svg @@ -1 +1 @@ - + diff --git a/assets/icons/codeBrackets_stroke2_corner2_rounded.svg b/assets/icons/codeBrackets_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..d40e7b003d --- /dev/null +++ b/assets/icons/codeBrackets_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/codeLines_stroke2_corner2_rounded.svg b/assets/icons/codeLines_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..65a7b1e856 --- /dev/null +++ b/assets/icons/codeLines_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/earth_stroke2_corner2_rounded.svg b/assets/icons/earth_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..b8922629a3 --- /dev/null +++ b/assets/icons/earth_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/freeze_stroke2_corner2_rounded.svg b/assets/icons/freeze_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..1091454782 --- /dev/null +++ b/assets/icons/freeze_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/haptic_stroke2_corner2_rounded.svg b/assets/icons/haptic_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..7a93daf669 --- /dev/null +++ b/assets/icons/haptic_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/home_stroke2_corner2_rounded.svg b/assets/icons/home_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..5665b24b88 --- /dev/null +++ b/assets/icons/home_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/key_stroke2_corner2_rounded.svg b/assets/icons/key_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..2beb2b4ae4 --- /dev/null +++ b/assets/icons/key_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/macintosh_stroke2_corner2_rounded.svg b/assets/icons/macintosh_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..5435319722 --- /dev/null +++ b/assets/icons/macintosh_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/newspaper_stroke2_corner2_rounded.svg b/assets/icons/newspaper_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..3eb823cf2c --- /dev/null +++ b/assets/icons/newspaper_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pencilLine_stroke2_corner2_rounded.svg b/assets/icons/pencilLine_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..92f1fed1c4 --- /dev/null +++ b/assets/icons/pencilLine_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/personGroup_stroke2_corner2_rounded.svg b/assets/icons/personGroup_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..fad8a8e7fb --- /dev/null +++ b/assets/icons/personGroup_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/raisingHand4finger_stroke2_corner2_rounded.svg b/assets/icons/raisingHand4finger_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..f6beb5647d --- /dev/null +++ b/assets/icons/raisingHand4finger_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/wrench_stroke2_corner2_rounded.svg b/assets/icons/wrench_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..601614c88f --- /dev/null +++ b/assets/icons/wrench_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 12b7b8960f..d0d6b4c494 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -253,6 +253,10 @@ func serve(cctx *cli.Context) error { e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/settings/accessibility", server.WebGeneric) e.GET("/settings/appearance", server.WebGeneric) + e.GET("/settings/account", server.WebGeneric) + e.GET("/settings/privacy-and-security", server.WebGeneric) + e.GET("/settings/content-and-media", server.WebGeneric) + e.GET("/settings/about", server.WebGeneric) e.GET("/sys/debug", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/log", server.WebGeneric) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 81d08c7da3..436f2488a5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -95,6 +95,10 @@ import {Wizard} from '#/screens/StarterPack/Wizard' import {useTheme} from '#/alf' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' +import {AboutSettingsScreen} from './screens/Settings/AboutSettings' +import {AccountSettingsScreen} from './screens/Settings/AccountSettings' +import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' +import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' const navigationRef = createNavigationContainerRef() @@ -322,7 +326,39 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { name="AppearanceSettings" getComponent={() => AppearanceSettingsScreen} options={{ - title: title(msg`Appearance Settings`), + title: title(msg`Appearance`), + requireAuth: true, + }} + /> + AccountSettingsScreen} + options={{ + title: title(msg`Account`), + requireAuth: true, + }} + /> + PrivacyAndSecuritySettingsScreen} + options={{ + title: title(msg`Privacy and Security`), + requireAuth: true, + }} + /> + ContentAndMediaSettingsScreen} + options={{ + title: title(msg`Content and Media`), + requireAuth: true, + }} + /> + AboutSettingsScreen} + options={{ + title: title(msg`About`), requireAuth: true, }} /> diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index ca4c14dc6d..434420a972 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -102,6 +102,9 @@ export const atoms = { /* * Flex */ + gap_0: { + gap: 0, + }, gap_2xs: { gap: tokens.space._2xs, }, diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 57e3731645..ea11e22174 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,16 +1,22 @@ -import React from 'react' +import React, {useContext, useMemo} from 'react' import {View, ViewStyle} from 'react-native' import {StyleProp} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {ScrollView} from '#/view/com/util/Views' +import {CenteredView} from '#/view/com/util/Views' import {atoms as a} from '#/alf' // Every screen should have a Layout component wrapping it. // This component provides a default padding for the top of the screen. // This allows certain screens to avoid the top padding if they want to. -// -// In a future PR I will add a unified header component to this file and -// things like a preconfigured scrollview. + +const LayoutContext = React.createContext({ + withinScreen: false, + topPaddingDisabled: false, + withinScrollView: false, +}) /** * Every screen should have a Layout.Screen component wrapping it. @@ -18,7 +24,7 @@ import {atoms as a} from '#/alf' * and height/minHeight */ let Screen = ({ - disableTopPadding, + disableTopPadding = false, style, ...props }: React.ComponentProps & { @@ -26,16 +32,69 @@ let Screen = ({ style?: StyleProp }): React.ReactNode => { const {top} = useSafeAreaInsets() + const context = useMemo( + () => ({ + withinScreen: true, + topPaddingDisabled: disableTopPadding, + withinScrollView: false, + }), + [disableTopPadding], + ) return ( - + + + ) } Screen = React.memo(Screen) export {Screen} + +let Header = ( + props: React.ComponentProps, +): React.ReactNode => { + const {withinScrollView} = useContext(LayoutContext) + if (!withinScrollView) { + return ( + + + + ) + } else { + return + } +} +Header = React.memo(Header) +export {Header} + +let Content = ({ + style, + contentContainerStyle, + ...props +}: React.ComponentProps & { + style?: StyleProp + contentContainerStyle?: StyleProp +}): React.ReactNode => { + const context = useContext(LayoutContext) + const newContext = useMemo( + () => ({...context, withinScrollView: true}), + [context], + ) + return ( + + + + ) +} +Content = React.memo(Content) +export {Content} diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index 81d0c6740e..8f47d05b06 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -29,7 +29,7 @@ export function BirthDateSettingsDialog({ const {isLoading, error, data: preferences} = usePreferencesQuery() return ( - + diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index ea870e2da9..daad01d2ab 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -56,6 +56,8 @@ export function SwitchAccountDialog({ pendingDid={pendingDid} /> + + ) diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx index ff6da25316..3654fcf785 100644 --- a/src/components/icons/Bubble.tsx +++ b/src/components/icons/Bubble.tsx @@ -11,3 +11,7 @@ export const Bubble_Stroke2_Corner2_Rounded = createSinglePathSVG({ export const Bubble_Stroke2_Corner3_Rounded = createSinglePathSVG({ path: 'M2.002 7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H12.28l-4.762 2.858A1 1 0 0 1 6.002 21v-2a4 4 0 0 1-4-4V7Zm4-2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h1a1 1 0 0 1 1 1v1.234l3.486-2.092a1 1 0 0 1 .514-.142h6a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-12Z', }) + +export const Bubbles_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M6.002 6a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-1v1a3 3 0 0 1-3 3h-4.24l-4.274 2.374a1 1 0 0 1-1.486-.874V19a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3h1V6Zm-1 3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1a1 1 0 0 1 1 1v.8l3.015-1.674a1 1 0 0 1 .485-.126h4.5a1 1 0 0 0 1-1v-1.933a1 1 0 0 1 0-.134V10a1 1 0 0 0-1-1h-10Zm13 4v-3a3 3 0 0 0-3-3h-7V6a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-1Z', +}) diff --git a/src/components/icons/Car.tsx b/src/components/icons/Car.tsx new file mode 100644 index 0000000000..25859176ac --- /dev/null +++ b/src/components/icons/Car.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Car_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M7.018 6a1 1 0 0 0-.808.412L5.4 5.824l.809.588L3 10.825V17a1 1 0 1 0 2 0 1 1 0 0 1 1-1h12a1 1 0 0 1 1 1 1 1 0 1 0 2 0v-5.998l-3.22-4.577A1 1 0 0 0 16.962 6H7.018ZM23 11.686V17a3 3 0 0 1-5.83 1H6.83A3.001 3.001 0 0 1 1 17v-5.5a1 1 0 1 1 0-2h.49l3.102-4.265A3 3 0 0 1 7.018 4h9.944a3 3 0 0 1 2.453 1.274l3.104 4.412H23a1 1 0 1 1 0 2ZM5 13a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2H6a1 1 0 0 1-1-1Zm10 0a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/CircleQuestion.tsx b/src/components/icons/CircleQuestion.tsx index 4eb369379b..6242a2b514 100644 --- a/src/components/icons/CircleQuestion.tsx +++ b/src/components/icons/CircleQuestion.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const CircleQuestion_Stroke2_Corner2_Rounded = createSinglePathSVG({ - path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Z" clip-rule="evenodd"/> + + + Terms of Service + + + + + + Privacy Policy + + + + + + Status Page + + + + + + + System log + + + { + 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`)) + }}> + + + Version {appVersion} + + {bundleInfo} + + + + + ) +} diff --git a/src/screens/Settings/AccessibilitySettings.tsx b/src/screens/Settings/AccessibilitySettings.tsx new file mode 100644 index 0000000000..dfe2c14a5f --- /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 ( + + + + + + + + Alt text + + setRequireAltTextEnabled(value)} + style={[a.w_full]}> + + Require alt text before posting + + + + setLargeAltBadgeEnabled(value)} + style={[a.w_full]}> + + Display larger alt text badges + + + + + {isNative && ( + <> + + + + + Haptics + + setHapticsDisabled(value)} + style={[a.w_full]}> + + Disable haptic feedback + + + + + + )} + + + + Autoplay options have moved to the{' '} + + Content and Media settings + + . + + + + + + + ) +} diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx new file mode 100644 index 0000000000..19101d2f45 --- /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 +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 ( + + + + + + + + Email + + {currentAccount && ( + <> + + {currentAccount.email || (no email)} + + {currentAccount.emailConfirmed ? ( + + ) : ( + {}} + /> + )} + + )} + + openModal({name: 'change-email'})}> + + + Change email + + + + + + + Protect your account + + + + + + + Birthday + + birthdayControl.open()} + /> + + openModal({name: 'change-password'})}> + + + Password + + + + + openModal({ + name: 'change-handle', + onChanged() { + if (currentAccount) { + // refresh my profile + queryClient.invalidateQueries({ + queryKey: RQKEY_PROFILE(currentAccount.did), + }) + } + }, + }) + }> + + + Handle + + {profile && ( + @{profile.handle} + )} + + + + exportCarControl.open()}> + + + Export my data + + + + deactivateAccountControl.open()} + destructive> + + + Deactivate account + + + + openModal({name: 'delete-account'})} + destructive> + + + Delete account + + + + + + + + + + + ) +} diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index c317c930fa..d0beb7d506 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 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 ( - - - - - Appearance - - - + + + + - - - - - {colorMode !== 'light' && ( - - - - )} + {colorMode !== 'light' && ( + + + + )} + - - - + + + ) @@ -205,29 +189,32 @@ export function AppearanceToggleButtonGroup({ }) { const t = useTheme() return ( - - - - - {title} - + <> + + + {title} {description && ( + style={[ + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + a.w_full, + ]}> {description} )} - - - {items.map(item => ( - - {item.label} - - ))} - - + + {items.map(item => ( + + {item.label} + + ))} + + + ) } diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx new file mode 100644 index 0000000000..79c8a48f3b --- /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 ( + + + + + + + + Manage saved feeds + + + + + + Thread preferences + + + + + + Following feed preferences + + + + + + External media + + + + {isNative && ( + setUseInAppBrowser(value)}> + + + + Use in-app browser to open links + + + + + )} + setAutoplayDisabledPref(value)}> + + + + Disable autoplay for videos and GIFs + + + + + + + + ) +} diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx new file mode 100644 index 0000000000..da462c90db --- /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 ( + + + + + + + + Two-factor authentication (2FA) + + + + + + + App passwords + + {appPasswords && appPasswords.length > 0 && ( + + {appPasswords.length} + + )} + + + + + + Logged-out visibility + + + + + + + + + + + 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. + + + + + Learn more about what is public on Bluesky. + + + + + + + + + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx new file mode 100644 index 0000000000..789ffb56f1 --- /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 +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 ( + + + + + + {profile && } + + 1 + ? _(msg`Switch account`) + : _(msg`Add another account`) + } + onPress={() => + accounts.length > 1 + ? switchAccountControl.open() + : onAddAnotherAccount() + }> + + + {accounts.length > 1 ? ( + Switch account + ) : ( + Add another account + )} + + {accounts.length > 1 && ( + acc.did) + .filter(did => did !== currentAccount?.did) + .slice(0, 5)} + /> + )} + + + + + + Account + + + + + + Privacy and security + + + + + + Moderation + + + + + + Content and media + + + + + + Appearance + + + + + + Accessibility + + + + + + Languages + + + Linking.openURL(HELP_DESK_URL)} + label={_(msg`Help`)} + accessibilityHint={_(msg`Open helpdesk in browser`)}> + + + Help + + + + + + + About + + + + signOutPromptControl.open()} + label={_(msg`Sign out`)}> + + Sign out + + + + + + logoutEveryAccount('Settings')} + confirmButtonCta={_(msg`Sign out`)} + cancelButtonCta={_(msg`Cancel`)} + confirmButtonColor="negative" + /> + + + + ) +} + +function ProfilePreview({ + profile, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const shadow = useProfileShadow(profile) + const moderationOpts = useModerationOpts() + + if (!moderationOpts) return null + + const moderation = moderateProfile(profile, moderationOpts) + + return ( + <> + + + + + ) +} + +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 ( + + {items.map((item, i) => ( + + {item.profile && ( + + )} + + ))} + + ) +} diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx new file mode 100644 index 0000000000..d89e5f18e4 --- /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 ( + <> + + + + + ) +} diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx new file mode 100644 index 0000000000..4339ade9be --- /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 ( + + + + + Discourage apps from showing my account to logged-out users + + + + + + + + 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. + + + + ) +} diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx new file mode 100644 index 0000000000..86f8040afd --- /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 {children} +} + +/** + * 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 + contentContainerStyle?: StyleProp +}) { + const context = useMemo( + () => ({destructive, withinGroup: true}), + [destructive], + ) + return ( + + + + + + + + {children} + + + + + ) +} + +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 +}) { + const context = useContext(ItemContext) + const childContext = useMemo(() => { + if (typeof destructive !== 'boolean') return context + return {...context, destructive} + }, [context, destructive]) + return ( + + + {children} + + + ) +} + +export function LinkItem({ + children, + destructive = false, + contentContainerStyle, + chevronColor, + ...props +}: LinkProps & { + contentContainerStyle?: StyleProp + destructive?: boolean + chevronColor?: string +}) { + const t = useTheme() + + return ( + + {args => ( + + {typeof children === 'function' ? children(args) : children} + + + )} + + ) +} + +export function PressableItem({ + children, + destructive = false, + contentContainerStyle, + hoverStyle, + ...props +}: Button.ButtonProps & { + contentContainerStyle?: StyleProp + destructive?: boolean +}) { + const t = useTheme() + return ( + + {args => ( + + {typeof children === 'function' ? children(args) : children} + + )} + + ) +} + +export function ItemIcon({ + icon: Comp, + size = 'xl', + color: colorProp, +}: Omit, '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 = ( + + + + ) + + if (withinGroup) { + return {content} + } else { + return content + } +} + +export function ItemText({ + // eslint-disable-next-line react/prop-types + style, + ...props +}: React.ComponentProps) { + const t = useTheme() + const {destructive, withinGroup} = useContext(ItemContext) + + const content = ( + + ) + + if (withinGroup) { + return {content} + } else { + return content + } +} + +export function Divider() { + const t = useTheme() + return ( + + ) +} + +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 +} + +export function BadgeText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function BadgeButton({ + label, + onPress, +}: { + label: string + onPress: (evt: GestureResponderEvent) => void +}) { + const t = useTheme() + return ( + + {({pressed}) => ( + + {label} + + )} + + ) +} diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 6dd248ca7e..b865d7bbf1 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -10,15 +10,15 @@ import {LinearGradient} from 'expo-linear-gradient' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {cleanError} from '#/lib/strings/errors' +import {colors, gradients, s} from '#/lib/styles' +import {useTheme} from '#/lib/ThemeContext' +import {isAndroid, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const' import {useAgent, useSession, useSessionApi} from '#/state/session' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {cleanError} from 'lib/strings/errors' -import {colors, gradients, s} from 'lib/styles' -import {useTheme} from 'lib/ThemeContext' -import {isAndroid, isWeb} from 'platform/detection' import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' import {atoms as a, useTheme as useNewTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' @@ -210,6 +210,7 @@ export function Component({}: {}) { to="#" onPress={e => { e.preventDefault() + closeModal() deactivateAccountControl.open() return false }}> diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx index bf9f5fcb5e..4dd5aa97be 100644 --- a/src/view/screens/AccessibilitySettings.tsx +++ b/src/view/screens/AccessibilitySettings.tsx @@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' +import {IS_INTERNAL} from '#/lib/app-info' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -26,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' +import {AccessibilitySettingsScreen as NewAccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' import {atoms as a} from '#/alf' import * as Layout from '#/components/Layout' @@ -33,7 +35,15 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'AccessibilitySettings' > -export function AccessibilitySettingsScreen({}: Props) { +export function AccessibilitySettingsScreen(props: Props) { + return IS_INTERNAL ? ( + + ) : ( + + ) +} + +function LegacyAccessibilitySettingsScreen({}: Props) { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile, isTabletOrMobile} = useWebMediaQueries() diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/view/screens/Settings/DisableEmail2FADialog.tsx index e4341fcd21..e3d0885177 100644 --- a/src/view/screens/Settings/DisableEmail2FADialog.tsx +++ b/src/view/screens/Settings/DisableEmail2FADialog.tsx @@ -108,7 +108,11 @@ export function DisableEmail2FADialog({ {error ? : undefined} {stage === Stages.Email ? ( - +