basic export repository link in settings (#2641)

* basic export repository link in settings

Absolutely no prior React experience, and limited TypeScript, so
probably doing all kinds of things wrong!

I tried to make it a download button instead of link but that didn't
work.

There is probably a safer way to construct the URL string.

I think having the download open in the browser is reasonable, as
opposed to an in-app save flow in mobile. But i'm not sure.

* Remove appview proxy toggle

* Move Settings screen to a subfolder

* Add support for the download attribute on links in web

* Rewrite ExportRepository modal using ALF

* Mobile ui tweaks

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
bnewbold 2024-02-12 15:22:03 -08:00 committed by GitHub
parent b308d7e65d
commit d7a3246fe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 161 additions and 96 deletions

View File

@ -148,6 +148,10 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
* Label for a11y. Defaults to the href. * Label for a11y. Defaults to the href.
*/ */
label?: string label?: string
/**
* Web-only attribute. Sets `download` attr on web.
*/
download?: string
} }
/** /**
@ -158,7 +162,13 @@ export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
* Intended to behave as a web anchor tag. For more complex routing, use a * Intended to behave as a web anchor tag. For more complex routing, use a
* `Button`. * `Button`.
*/ */
export function Link({children, to, action = 'push', ...rest}: LinkProps) { export function Link({
children,
to,
action = 'push',
download,
...rest
}: LinkProps) {
const {href, isExternal, onPress} = useLink({ const {href, isExternal, onPress} = useLink({
to, to,
displayText: typeof children === 'string' ? children : '', displayText: typeof children === 'string' ? children : '',
@ -177,6 +187,7 @@ export function Link({children, to, action = 'push', ...rest}: LinkProps) {
hrefAttrs: { hrefAttrs: {
target: isExternal ? 'blank' : undefined, target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined, rel: isExternal ? 'noopener noreferrer' : undefined,
download,
}, },
dataSet: { dataSet: {
// default to no underline, apply this ourselves // default to no underline, apply this ourselves

View File

@ -1,60 +0,0 @@
/**
* APP-700
*
* This is a temporary debug setting we're running on the Web build to
* help the protocol team test some changes.
*
* It should be removed in ~2 weeks. It should only be used on the Web
* version of the app.
*/
import {useState, useCallback, useEffect} from 'react'
import {BskyAgent} from '@atproto/api'
import * as Storage from 'lib/storage'
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
const [enabled, setEnabled] = useState<boolean>(false)
useEffect(() => {
async function check() {
if (await isEnabled()) {
setEnabled(true)
}
}
check()
}, [])
const toggle = useCallback(() => {
if (!enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
setEnabled(true)
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
setEnabled(false)
}
}, [setEnabled, enabled, agent])
return [enabled, toggle]
}
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
if (enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
}
}
export async function applyDebugHeader(agent: BskyAgent) {
if (await isEnabled()) {
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
}
}
async function isEnabled() {
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
}

View File

@ -39,6 +39,7 @@ import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash' import {faCommentSlash} from '@fortawesome/free-solid-svg-icons/faCommentSlash'
import {faComments} from '@fortawesome/free-regular-svg-icons/faComments' import {faComments} from '@fortawesome/free-regular-svg-icons/faComments'
import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass' import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
import {faDownload} from '@fortawesome/free-solid-svg-icons/faDownload'
import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis' import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope' import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
@ -143,6 +144,7 @@ library.add(
faCommentSlash, faCommentSlash,
faComments, faComments,
faCompass, faCompass,
faDownload,
faEllipsis, faEllipsis,
faEnvelope, faEnvelope,
faEye, faEye,

View File

@ -0,0 +1,103 @@
import React from 'react'
import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text, P} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {InlineLink, Link} from '#/components/Link'
import {getAgent, useSession} from '#/state/session'
export function ExportCarDialog({
control,
}: {
control: Dialog.DialogOuterProps['control']
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const downloadUrl = React.useMemo(() => {
const agent = getAgent()
if (!currentAccount || !agent.session) {
return '' // shouldnt ever happen
}
// eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz
const url = new URL(agent.pdsUrl || agent.service)
url.pathname = '/xrpc/com.atproto.sync.getRepo'
url.searchParams.set('did', agent.session.did)
return url.toString()
}, [currentAccount])
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Export My Data</Trans>
</Text>
<P nativeID="dialog-description" style={[a.text_sm]}>
<Trans>
Your account repository, containing all public data records, can
be downloaded as a "CAR" file. This file does not include media
embeds, such as images, or your private data, which must be
fetched separately.
</Trans>
</P>
<Link
variant="solid"
color="primary"
size="large"
label={_(msg`Download CAR file`)}
to={downloadUrl}
download="repo.car">
<ButtonText>
<Trans>Download CAR file</Trans>
</ButtonText>
</Link>
<P
style={[
a.py_xs,
t.atoms.text_contrast_medium,
a.text_sm,
a.leading_snug,
a.flex_1,
]}>
<Trans>
This feature is in beta. You can read more about repository
exports in{' '}
<InlineLink
to="https://atproto.com/blog/repo-export"
style={[a.text_sm]}>
this blogpost.
</InlineLink>
</Trans>
</P>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={() => control.close()}
label={_(msg`Done`)}>
{_(msg`Done`)}
</Button>
</View>
{!gtMobile && <View style={{height: 40}} />}
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}

View File

@ -17,14 +17,6 @@ import {
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import * as AppInfo from 'lib/app-info' import * as AppInfo from 'lib/app-info'
import {s, colors} from 'lib/styles'
import {ScrollView} from '../com/util/Views'
import {Link, TextLink} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import * as Toast from '../com/util/Toast'
import {UserAvatar} from '../com/util/UserAvatar'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useCustomPalette} from 'lib/hooks/useCustomPalette' import {useCustomPalette} from 'lib/hooks/useCustomPalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -34,8 +26,6 @@ import {NavigationProp} from 'lib/routes/types'
import {HandIcon, HashtagIcon} from 'lib/icons' import {HandIcon, HashtagIcon} from 'lib/icons'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {
@ -48,22 +38,12 @@ import {
useRequireAltTextEnabled, useRequireAltTextEnabled,
useSetRequireAltTextEnabled, useSetRequireAltTextEnabled,
} from '#/state/preferences' } from '#/state/preferences'
import { import {useSession, useSessionApi, SessionAccount} from '#/state/session'
useSession,
useSessionApi,
SessionAccount,
getAgent,
} from '#/state/session'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useClearPreferencesMutation} from '#/state/queries/preferences' import {useClearPreferencesMutation} from '#/state/queries/preferences'
import {useInviteCodesQuery} from '#/state/queries/invites' import {useInviteCodesQuery} from '#/state/queries/invites'
import {clear as clearStorage} from '#/state/persisted/store' import {clear as clearStorage} from '#/state/persisted/store'
import {clearLegacyStorage} from '#/state/persisted/legacy' import {clearLegacyStorage} from '#/state/persisted/legacy'
// TEMPORARY (APP-700)
// remove after backend testing finishes
// -prf
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
import {STATUS_PAGE_URL} from 'lib/constants' import {STATUS_PAGE_URL} from 'lib/constants'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -75,6 +55,19 @@ import {
useSetInAppBrowser, useSetInAppBrowser,
} from '#/state/preferences/in-app-browser' } from '#/state/preferences/in-app-browser'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useDialogControl} from '#/components/Dialog'
import {s, colors} from 'lib/styles'
import {ScrollView} from 'view/com/util/Views'
import {Link, TextLink} from 'view/com/util/Link'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {ExportCarDialog} from './ExportCarDialog'
function SettingsAccountCard({account}: {account: SessionAccount}) { function SettingsAccountCard({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
@ -159,14 +152,12 @@ export function SettingsScreen({}: Props) {
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {isSwitchingAccounts, accounts, currentAccount} = useSession() const {isSwitchingAccounts, accounts, currentAccount} = useSession()
const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting(
getAgent(),
)
const {mutate: clearPreferences} = useClearPreferencesMutation() const {mutate: clearPreferences} = useClearPreferencesMutation()
const {data: invites} = useInviteCodesQuery() const {data: invites} = useInviteCodesQuery()
const invitesAvailable = invites?.available?.length ?? 0 const invitesAvailable = invites?.available?.length ?? 0
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const primaryBg = useCustomPalette<ViewStyle>({ const primaryBg = useCustomPalette<ViewStyle>({
light: {backgroundColor: colors.blue0}, light: {backgroundColor: colors.blue0},
@ -214,6 +205,10 @@ export function SettingsScreen({}: Props) {
}) })
}, [track, queryClient, openModal, currentAccount]) }, [track, queryClient, openModal, currentAccount])
const onPressExportRepository = React.useCallback(() => {
exportCarControl.open()
}, [exportCarControl])
const onPressInviteCodes = React.useCallback(() => { const onPressInviteCodes = React.useCallback(() => {
track('Settings:InvitecodesButtonClicked') track('Settings:InvitecodesButtonClicked')
openModal({name: 'invite-codes'}) openModal({name: 'invite-codes'})
@ -282,6 +277,8 @@ export function SettingsScreen({}: Props) {
return ( return (
<View style={s.hContentRegion} testID="settingsScreen"> <View style={s.hContentRegion} testID="settingsScreen">
<ExportCarDialog control={exportCarControl} />
<SimpleViewHeader <SimpleViewHeader
showBackButton={isMobile} showBackButton={isMobile}
style={[ style={[
@ -735,6 +732,29 @@ export function SettingsScreen({}: Props) {
<Trans>Change Password</Trans> <Trans>Change Password</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
testID="exportRepositoryBtn"
style={[
styles.linkCard,
pal.view,
isSwitchingAccounts && styles.dimmed,
]}
onPress={isSwitchingAccounts ? undefined : onPressExportRepository}
accessibilityRole="button"
accessibilityLabel={_(msg`Export my data`)}
accessibilityHint={_(
msg`Download Bluesky account data (repository)`,
)}>
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="download"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text} numberOfLines={1}>
<Trans>Export My Data</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCard]} style={[pal.view, styles.linkCard]}
onPress={onPressDeleteAccount} onPress={onPressDeleteAccount}
@ -756,9 +776,6 @@ export function SettingsScreen({}: Props) {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.spacer20} /> <View style={styles.spacer20} />
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Developer Tools</Trans>
</Text>
<TouchableOpacity <TouchableOpacity
style={[pal.view, styles.linkCardNoIcon]} style={[pal.view, styles.linkCardNoIcon]}
onPress={onPressSystemLog} onPress={onPressSystemLog}
@ -769,14 +786,6 @@ export function SettingsScreen({}: Props) {
<Trans>System log</Trans> <Trans>System log</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{__DEV__ ? (
<ToggleButton
type="default-light"
label="Experiment: Use AppView Proxy"
isSelected={debugHeaderEnabled}
onPress={toggleDebugHeader}
/>
) : null}
{__DEV__ ? ( {__DEV__ ? (
<> <>
<TouchableOpacity <TouchableOpacity