Merge remote-tracking branch 'upstream/main' into make-more-localizable

This commit is contained in:
Minseo Lee
2024-05-13 10:31:09 +09:00
16 changed files with 332 additions and 159 deletions
+1 -1
View File
@@ -51,7 +51,7 @@
}, },
"dependencies": { "dependencies": {
"@atproto-labs/api": "^0.12.8-clipclops.0", "@atproto-labs/api": "^0.12.8-clipclops.0",
"@atproto/api": "^0.12.6", "@atproto/api": "^0.12.9",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+1 -3
View File
@@ -32,9 +32,7 @@ export function LabelsOnMe({
if (!labels || !currentAccount) { if (!labels || !currentAccount) {
return null return null
} }
labels = labels.filter( labels = labels.filter(l => !l.val.startsWith('!'))
l => !l.val.startsWith('!') && l.src !== currentAccount.did,
)
if (!labels.length) { if (!labels.length) {
return null return null
} }
+61 -35
View File
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useAgent} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
@@ -33,13 +33,28 @@ export interface LabelsOnMeDialogProps {
labels: ComAtprotoLabelDefs.Label[] labels: ComAtprotoLabelDefs.Label[]
} }
export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LabelsOnMeDialogInner {...props} />
</Dialog.Outer>
)
}
function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
const {_} = useLingui() const {_} = useLingui()
const {currentAccount} = useSession()
const [appealingLabel, setAppealingLabel] = React.useState< const [appealingLabel, setAppealingLabel] = React.useState<
ComAtprotoLabelDefs.Label | undefined ComAtprotoLabelDefs.Label | undefined
>(undefined) >(undefined)
const {subject, labels} = props const {subject, labels} = props
const isAccount = 'did' in subject const isAccount = 'did' in subject
const containsSelfLabel = React.useMemo(
() => labels.some(l => l.src === currentAccount?.did),
[currentAccount?.did, labels],
)
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
@@ -65,9 +80,17 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
)} )}
</Text> </Text>
<Text style={[a.text_md, a.leading_snug]}> <Text style={[a.text_md, a.leading_snug]}>
<Trans> {containsSelfLabel ? (
You may appeal these labels if you feel they were placed in error. <Trans>
</Trans> You may appeal non-self labels if you feel they were placed in
error.
</Trans>
) : (
<Trans>
You may appeal these labels if you feel they were placed in
error.
</Trans>
)}
</Text> </Text>
<View style={[a.py_lg, a.gap_md]}> <View style={[a.py_lg, a.gap_md]}>
@@ -75,6 +98,7 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
<Label <Label
key={`${label.val}-${label.src}`} key={`${label.val}-${label.src}`}
label={label} label={label}
isSelfLabel={label.src === currentAccount?.did}
control={props.control} control={props.control}
onPressAppeal={label => setAppealingLabel(label)} onPressAppeal={label => setAppealingLabel(label)}
/> />
@@ -88,22 +112,14 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
) )
} }
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LabelsOnMeDialogInner {...props} />
</Dialog.Outer>
)
}
function Label({ function Label({
label, label,
isSelfLabel,
control, control,
onPressAppeal, onPressAppeal,
}: { }: {
label: ComAtprotoLabelDefs.Label label: ComAtprotoLabelDefs.Label
isSelfLabel: boolean
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
}) { }) {
@@ -125,32 +141,42 @@ function Label({
{strings.description} {strings.description}
</Text> </Text>
</View> </View>
<View> {!isSelfLabel && (
<Button <View>
variant="solid" <Button
color="secondary" variant="solid"
size="small" color="secondary"
label={_(msg`Appeal`)} size="small"
onPress={() => onPressAppeal(label)}> label={_(msg`Appeal`)}
<ButtonText> onPress={() => onPressAppeal(label)}>
<Trans>Appeal</Trans> <ButtonText>
</ButtonText> <Trans>Appeal</Trans>
</Button> </ButtonText>
</View> </Button>
</View>
)}
</View> </View>
<Divider /> <Divider />
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}> <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
<Text style={[t.atoms.text_contrast_medium]}> <Text style={[t.atoms.text_contrast_medium]}>
<Trans>Source:</Trans>{' '} {isSelfLabel ? (
<InlineLinkText <Trans>This label was applied by you</Trans>
to={makeProfileLink( ) : (
labeler ? labeler.creator : {did: label.src, handle: ''}, <>
)} <Trans>Source:</Trans>{' '}
onPress={() => control.close()}> <InlineLinkText
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} to={makeProfileLink(
</InlineLinkText> labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src}
</InlineLinkText>
</>
)}
</Text> </Text>
</View> </View>
</View> </View>
+4 -2
View File
@@ -1,5 +1,5 @@
import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
const GET_TIMEOUT = 15e3 // 15s const GET_TIMEOUT = 15e3 // 15s
const POST_TIMEOUT = 60e3 // 60s const POST_TIMEOUT = 60e3 // 60s
@@ -68,8 +68,10 @@ async function fetchHandler(
resBody = jsonToLex(await res.json()) resBody = jsonToLex(await res.json())
} else if (resMimeType.startsWith('text/')) { } else if (resMimeType.startsWith('text/')) {
resBody = await res.text() resBody = await res.text()
} else if (resMimeType === 'application/vnd.ipld.car') {
resBody = await res.arrayBuffer()
} else { } else {
throw new Error('TODO: non-textual response body') throw new Error('Non-supported mime type')
} }
} }
+4
View File
@@ -71,6 +71,10 @@ export class FeedViewPostsSlice {
?.__source as ReasonFeedSource ?.__source as ReasonFeedSource
} }
get feedContext() {
return this.items.find(item => item.feedContext)?.feedContext
}
containsUri(uri: string) { containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri) return !!this.items.find(item => item.post.uri === uri)
} }
+73 -1
View File
@@ -1,12 +1,23 @@
import {Image as RNImage, Share as RNShare} from 'react-native' import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image} from 'react-native-image-crop-picker' import {Image} from 'react-native-image-crop-picker'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import {cacheDirectory, copyAsync, deleteAsync} from 'expo-file-system' import {
cacheDirectory,
copyAsync,
deleteAsync,
documentDirectory,
EncodingType,
makeDirectoryAsync,
StorageAccessFramework,
writeAsStringAsync,
} from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import ImageResizer from '@bam.tech/react-native-image-resizer' import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob' import RNFetchBlob from 'rn-fetch-blob'
import {logger} from '#/logger'
import {isAndroid, isIOS} from 'platform/detection' import {isAndroid, isIOS} from 'platform/detection'
import {Dimensions} from './types' import {Dimensions} from './types'
@@ -240,3 +251,64 @@ function normalizePath(str: string, allPlatforms = false): string {
} }
return str return str
} }
export async function saveBytesToDisk(
filename: string,
bytes: Uint8Array,
type: string,
) {
const encoded = Buffer.from(bytes).toString('base64')
return await saveToDevice(filename, encoded, type)
}
export async function saveToDevice(
filename: string,
encoded: string,
type: string,
) {
try {
if (isIOS) {
const tmpFileUrl = await withTempFile(filename, encoded)
await Sharing.shareAsync(tmpFileUrl, {UTI: type})
safeDeleteAsync(tmpFileUrl)
return true
} else {
const permissions =
await StorageAccessFramework.requestDirectoryPermissionsAsync()
if (!permissions.granted) {
return false
}
const fileUrl = await StorageAccessFramework.createFileAsync(
permissions.directoryUri,
filename,
type,
)
await writeAsStringAsync(fileUrl, encoded, {
encoding: EncodingType.Base64,
})
return true
}
} catch (e) {
logger.error('Error occurred while saving file', {message: e})
return false
}
}
async function withTempFile(
filename: string,
encoded: string,
): Promise<string> {
// Using a directory so that the file name is not a random string
// documentDirectory will always be available on native, so we assert as a string.
const tmpDirUri = joinPath(documentDirectory as string, String(uuid.v4()))
await makeDirectoryAsync(tmpDirUri, {intermediates: true})
const tmpFileUrl = joinPath(tmpDirUri, filename)
await writeAsStringAsync(tmpFileUrl, encoded, {
encoding: EncodingType.Base64,
})
return tmpFileUrl
}
+23 -2
View File
@@ -1,6 +1,7 @@
import {Dimensions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {getDataUriSize, blobToDataUri} from './util'
import {Dimensions} from './types'
import {blobToDataUri, getDataUriSize} from './util'
export async function compressIfNeeded( export async function compressIfNeeded(
img: RNImage, img: RNImage,
@@ -138,3 +139,23 @@ function createResizedImage(
img.src = dataUri img.src = dataUri
}) })
} }
export async function saveBytesToDisk(
filename: string,
bytes: Uint8Array,
type: string,
) {
const blob = new Blob([bytes], {type})
const url = URL.createObjectURL(blob)
await downloadUrl(url, filename)
// Firefox requires a small delay
setTimeout(() => URL.revokeObjectURL(url), 100)
return true
}
async function downloadUrl(href: string, filename: string) {
const a = document.createElement('a')
a.href = href
a.download = filename
a.click()
}
+35 -20
View File
@@ -3,13 +3,18 @@ import {View} from 'react-native'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants' import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants'
import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants' import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useOverwriteSavedFeedsMutation} from '#/state/queries/preferences' import {
preferencesQueryKey,
useOverwriteSavedFeedsMutation,
} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell' import {useOnboardingDispatch} from '#/state/shell'
import {uploadBlob} from 'lib/api' import {uploadBlob} from 'lib/api'
@@ -41,6 +46,7 @@ export function StepFinished() {
const onboardDispatch = useOnboardingDispatch() const onboardDispatch = useOnboardingDispatch()
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation() const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation()
const queryClient = useQueryClient()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const gate = useGate() const gate = useGate()
@@ -112,33 +118,41 @@ export function StepFinished() {
]) ])
} }
})(), })(),
])
if (gate('reduced_onboarding_and_home_algo')) { (async () => {
await getAgent().upsertProfile(async existing => { const {imageUri, imageMime} = profileStepResults
existing = existing ?? {} if (imageUri && imageMime) {
const blobPromise = uploadBlob(getAgent(), imageUri, imageMime)
if (profileStepResults.imageUri && profileStepResults.imageMime) { await getAgent().upsertProfile(async existing => {
const res = await uploadBlob( existing = existing ?? {}
getAgent(), const res = await blobPromise
profileStepResults.imageUri, if (res.data.blob) {
profileStepResults.imageMime, existing.avatar = res.data.blob
) }
return existing
if (res.data.blob) { })
existing.avatar = res.data.blob
}
} }
})(),
return existing ])
})
}
} catch (e: any) { } catch (e: any) {
logger.info(`onboarding: bulk save failed`) logger.info(`onboarding: bulk save failed`)
logger.error(e) logger.error(e)
// don't alert the user, just let them into their account // don't alert the user, just let them into their account
} }
// Try to ensure that prefs and profile are up-to-date by the time we render Home.
await Promise.all([
queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
}),
queryClient.invalidateQueries({
queryKey: profileRQKey(getAgent().session?.did ?? ''),
}),
]).catch(e => {
logger.error(e)
// Keep going.
})
setSaving(false) setSaving(false)
dispatch({type: 'finish'}) dispatch({type: 'finish'})
onboardDispatch({type: 'finish'}) onboardDispatch({type: 'finish'})
@@ -154,6 +168,7 @@ export function StepFinished() {
track, track,
getAgent, getAgent,
gate, gate,
queryClient,
]) ])
React.useEffect(() => { React.useEffect(() => {
+1 -1
View File
@@ -303,7 +303,7 @@ export function usePostFeedQuery(
i === 0 && slice.source i === 0 && slice.source
? slice.source ? slice.source
: item.reason, : item.reason,
feedContext: item.feedContext, feedContext: item.feedContext || slice.feedContext,
moderation: moderations[i], moderation: moderations[i],
} }
} }
+40
View File
@@ -6,6 +6,7 @@ import {
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {replaceEqualDeep} from '#/lib/functions' import {replaceEqualDeep} from '#/lib/functions'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
@@ -244,6 +245,45 @@ export function useRemoveFeedMutation() {
}) })
} }
export function useReplaceForYouWithDiscoverFeedMutation() {
const queryClient = useQueryClient()
const {getAgent} = useAgent()
return useMutation({
mutationFn: async ({
forYouFeedConfig,
discoverFeedConfig,
}: {
forYouFeedConfig: AppBskyActorDefs.SavedFeed | undefined
discoverFeedConfig: AppBskyActorDefs.SavedFeed | undefined
}) => {
if (forYouFeedConfig) {
await getAgent().removeSavedFeeds([forYouFeedConfig.id])
}
if (!discoverFeedConfig) {
await getAgent().addSavedFeeds([
{
type: 'feed',
value: PROD_DEFAULT_FEED('whats-hot'),
pinned: true,
},
])
} else {
await getAgent().updateSavedFeeds([
{
...discoverFeedConfig,
pinned: true,
},
])
}
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useUpdateSavedFeedsMutation() { export function useUpdateSavedFeedsMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {getAgent} = useAgent() const {getAgent} = useAgent()
+25 -12
View File
@@ -6,6 +6,7 @@ import {
AppBskyActorProfile, AppBskyActorProfile,
AtUri, AtUri,
BskyAgent, BskyAgent,
ComAtprotoRepoUploadBlob,
} from '@atproto/api' } from '@atproto/api'
import { import {
QueryClient, QueryClient,
@@ -124,6 +125,26 @@ export function useProfileUpdateMutation() {
newUserBanner, newUserBanner,
checkCommitted, checkCommitted,
}) => { }) => {
let newUserAvatarPromise:
| Promise<ComAtprotoRepoUploadBlob.Response>
| undefined
if (newUserAvatar) {
newUserAvatarPromise = uploadBlob(
getAgent(),
newUserAvatar.path,
newUserAvatar.mime,
)
}
let newUserBannerPromise:
| Promise<ComAtprotoRepoUploadBlob.Response>
| undefined
if (newUserBanner) {
newUserBannerPromise = uploadBlob(
getAgent(),
newUserBanner.path,
newUserBanner.mime,
)
}
await getAgent().upsertProfile(async existing => { await getAgent().upsertProfile(async existing => {
existing = existing || {} existing = existing || {}
if (typeof updates === 'function') { if (typeof updates === 'function') {
@@ -132,22 +153,14 @@ export function useProfileUpdateMutation() {
existing.displayName = updates.displayName existing.displayName = updates.displayName
existing.description = updates.description existing.description = updates.description
} }
if (newUserAvatar) { if (newUserAvatarPromise) {
const res = await uploadBlob( const res = await newUserAvatarPromise
getAgent(),
newUserAvatar.path,
newUserAvatar.mime,
)
existing.avatar = res.data.blob existing.avatar = res.data.blob
} else if (newUserAvatar === null) { } else if (newUserAvatar === null) {
existing.avatar = undefined existing.avatar = undefined
} }
if (newUserBanner) { if (newUserBannerPromise) {
const res = await uploadBlob( const res = await newUserBannerPromise
getAgent(),
newUserBanner.path,
newUserBanner.mime,
)
existing.banner = res.data.blob existing.banner = res.data.blob
} else if (newUserBanner === null) { } else if (newUserBanner === null) {
existing.banner = undefined existing.banner = undefined
+14 -34
View File
@@ -6,10 +6,9 @@ import {useLingui} from '@lingui/react'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import { import {
useAddSavedFeedsMutation,
usePreferencesQuery, usePreferencesQuery,
useRemoveFeedMutation, useRemoveFeedMutation,
useUpdateSavedFeedsMutation, useReplaceForYouWithDiscoverFeedMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useSetSelectedFeed} from '#/state/shell/selected-feed' import {useSetSelectedFeed} from '#/state/shell/selected-feed'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -24,12 +23,10 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
const {_} = useLingui() const {_} = useLingui()
const setSelectedFeed = useSetSelectedFeed() const setSelectedFeed = useSetSelectedFeed()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} =
useAddSavedFeedsMutation()
const {mutateAsync: removeFeed, isPending: isRemovePending} = const {mutateAsync: removeFeed, isPending: isRemovePending} =
useRemoveFeedMutation() useRemoveFeedMutation()
const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = const {mutateAsync: replaceFeedWithDiscover, isPending: isReplacePending} =
useUpdateSavedFeedsMutation() useReplaceForYouWithDiscoverFeedMutation()
const feedConfig = preferences?.savedFeeds?.find( const feedConfig = preferences?.savedFeeds?.find(
f => f.value === feedUri && f.pinned, f => f.value === feedUri && f.pinned,
@@ -46,6 +43,9 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
await removeFeed(feedConfig) await removeFeed(feedConfig)
Toast.show(_(msg`Removed from your feeds`)) Toast.show(_(msg`Removed from your feeds`))
} }
if (hasDiscoverPinned) {
setSelectedFeed(`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`)
}
} catch (err: any) { } catch (err: any) {
Toast.show( Toast.show(
_( _(
@@ -54,30 +54,15 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
) )
logger.error('Failed up update feeds', {message: err}) logger.error('Failed up update feeds', {message: err})
} }
}, [removeFeed, feedConfig, _]) }, [removeFeed, feedConfig, _, hasDiscoverPinned, setSelectedFeed])
const onReplaceFeed = React.useCallback(async () => { const onReplaceFeed = React.useCallback(async () => {
try { try {
if (!discoverFeedConfig) { await replaceFeedWithDiscover({
await addSavedFeeds([ forYouFeedConfig: feedConfig,
{ discoverFeedConfig,
type: 'feed', })
value: PROD_DEFAULT_FEED('whats-hot'),
pinned: true,
},
])
} else {
await updateSavedFeeds([
{
...discoverFeedConfig,
pinned: true,
},
])
}
setSelectedFeed(`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`) setSelectedFeed(`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`)
if (feedConfig) {
await removeFeed(feedConfig)
}
Toast.show(_(msg`The feed has been replaced with Discover.`)) Toast.show(_(msg`The feed has been replaced with Discover.`))
} catch (err: any) { } catch (err: any) {
Toast.show( Toast.show(
@@ -88,17 +73,14 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
logger.error('Failed up update feeds', {message: err}) logger.error('Failed up update feeds', {message: err})
} }
}, [ }, [
addSavedFeeds, replaceFeedWithDiscover,
updateSavedFeeds,
removeFeed,
discoverFeedConfig, discoverFeedConfig,
feedConfig, feedConfig,
setSelectedFeed, setSelectedFeed,
_, _,
]) ])
const isProcessing = const isProcessing = isReplacePending || isRemovePending
isAddSavedFeedPending || isUpdateFeedPending || isRemovePending
return ( return (
<View <View
style={[ style={[
@@ -147,9 +129,7 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
<ButtonText> <ButtonText>
<Trans>Replace with Discover</Trans> <Trans>Replace with Discover</Trans>
</ButtonText> </ButtonText>
{(isAddSavedFeedPending || isUpdateFeedPending) && ( {isReplacePending && <ButtonIcon icon={Loader} />}
<ButtonIcon icon={Loader} />
)}
</Button> </Button>
)} )}
</View> </View>
+1 -1
View File
@@ -90,7 +90,7 @@ function ListImpl<ItemT>(
}, },
{ {
itemVisiblePercentThreshold: 40, itemVisiblePercentThreshold: 40,
minimumViewTime: 2e3, minimumViewTime: 1.5e3,
}, },
] ]
}, [onItemSeen]) }, [onItemSeen])
+1 -1
View File
@@ -27,7 +27,7 @@ export type ListProps<ItemT> = Omit<
} }
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types. export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
const ON_ITEM_SEEN_WAIT_DURATION = 2e3 // post must be "seen" 2 seconds before capturing const ON_ITEM_SEEN_WAIT_DURATION = 1.5e3 // when we consider post to be "seen"
const ON_ITEM_SEEN_INTERSECTION_OPTS = { const ON_ITEM_SEEN_INTERSECTION_OPTS = {
rootMargin: '-200px 0px -200px 0px', rootMargin: '-200px 0px -200px 0px',
} // post must be 200px visible to be "seen" } // post must be 200px visible to be "seen"
+44 -42
View File
@@ -3,12 +3,16 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgent, useSession} from '#/state/session' import {saveBytesToDisk} from '#/lib/media/manip'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {logger} from '#/logger'
import {Button, ButtonText} from '#/components/Button' import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {P, Text} from '#/components/Typography' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function ExportCarDialog({ export function ExportCarDialog({
control, control,
@@ -17,21 +21,35 @@ export function ExportCarDialog({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const [loading, setLoading] = React.useState(false)
const downloadUrl = React.useMemo(() => { const download = React.useCallback(async () => {
const agent = getAgent() const agent = getAgent()
if (!currentAccount || !agent.session) { if (!agent.session) {
return '' // shouldnt ever happen return // shouldnt ever happen
} }
// eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz try {
const url = new URL(agent.pdsUrl || agent.service) setLoading(true)
url.pathname = '/xrpc/com.atproto.sync.getRepo' const did = agent.session.did
url.searchParams.set('did', agent.session.did) const downloadRes = await agent.com.atproto.sync.getRepo({did})
return url.toString() const saveRes = await saveBytesToDisk(
}, [currentAccount, getAgent]) 'repo.car',
downloadRes.data,
downloadRes.headers['content-type'],
)
if (saveRes) {
Toast.show(_(msg`File saved successfully!`))
}
} catch (e) {
logger.error('Error occurred while downloading CAR file', {message: e})
Toast.show(_(msg`Error occurred while saving file`))
} finally {
setLoading(false)
control.close()
}
}, [_, control, getAgent])
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
@@ -40,34 +58,34 @@ export function ExportCarDialog({
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}> <View style={[a.relative, a.gap_lg, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Export My Data</Trans> <Trans>Export My Data</Trans>
</Text> </Text>
<P nativeID="dialog-description" style={[a.text_sm]}> <Text nativeID="dialog-description" style={[a.text_sm]}>
<Trans> <Trans>
Your account repository, containing all public data records, can Your account repository, containing all public data records, can
be downloaded as a "CAR" file. This file does not include media be downloaded as a "CAR" file. This file does not include media
embeds, such as images, or your private data, which must be embeds, such as images, or your private data, which must be
fetched separately. fetched separately.
</Trans> </Trans>
</P> </Text>
<Link <Button
variant="solid" variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Download CAR file`)} label={_(msg`Download CAR file`)}
to={downloadUrl} disabled={loading}
download="repo.car"> onPress={download}>
<ButtonText> <ButtonText>
<Trans>Download CAR file</Trans> <Trans>Download CAR file</Trans>
</ButtonText> </ButtonText>
</Link> {loading && <ButtonIcon icon={Loader} />}
</Button>
<P <Text
style={[ style={[
a.py_xs,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.text_sm, a.text_sm,
a.leading_snug, a.leading_snug,
@@ -83,23 +101,7 @@ export function ExportCarDialog({
</InlineLinkText> </InlineLinkText>
. .
</Trans> </Trans>
</P> </Text>
<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`)}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
{!gtMobile && <View style={{height: 40}} />}
</View> </View>
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
+4 -4
View File
@@ -58,10 +58,10 @@
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
"@atproto/api@^0.12.6": "@atproto/api@^0.12.9":
version "0.12.6" version "0.12.9"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.6.tgz#690c004c5ac7fc7bceac4605d8c1ec1f580be270" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.9.tgz#5ae040980e574a5d9496368c4ca032c0cda174ec"
integrity sha512-30htXN2Hjl1jzzeAtIhggOsVS4vA975pMUQYoA4xMonug+z6O9NHcka3yYb4C9ldpnGugvRPKH7EhAUbiDTC5w== integrity sha512-3D4n2ZAAsDRnjevvcoIxQxuMMoqc+7vtVyP7EnrEdeOmRSCF9j8yXTqhn6rcHCbzcs3DKyYR26nQemtZsMsE0g==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.0"