diff --git a/package.json b/package.json index a343c063d9..4ed2b933fe 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ }, "dependencies": { "@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", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index ea5c74f9e2..77d0e2d939 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -32,9 +32,7 @@ export function LabelsOnMe({ if (!labels || !currentAccount) { return null } - labels = labels.filter( - l => !l.val.startsWith('!') && l.src !== currentAccount.did, - ) + labels = labels.filter(l => !l.val.startsWith('!')) if (!labels.length) { return null } diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index 176b04941e..858ac9ce4a 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react' import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {makeProfileLink} from '#/lib/routes/links' 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 {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' @@ -33,13 +33,28 @@ export interface LabelsOnMeDialogProps { labels: ComAtprotoLabelDefs.Label[] } -export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { +export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { + return ( + + + + + + ) +} + +function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { const {_} = useLingui() + const {currentAccount} = useSession() const [appealingLabel, setAppealingLabel] = React.useState< ComAtprotoLabelDefs.Label | undefined >(undefined) const {subject, labels} = props const isAccount = 'did' in subject + const containsSelfLabel = React.useMemo( + () => labels.some(l => l.src === currentAccount?.did), + [currentAccount?.did, labels], + ) return ( - - You may appeal these labels if you feel they were placed in error. - + {containsSelfLabel ? ( + + You may appeal non-self labels if you feel they were placed in + error. + + ) : ( + + You may appeal these labels if you feel they were placed in + error. + + )} @@ -75,6 +98,7 @@ export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) { - - - + {!isSelfLabel && ( + + + + )} - Source:{' '} - control.close()}> - {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src} - + {isSelfLabel ? ( + This label was applied by you + ) : ( + <> + Source:{' '} + control.close()}> + {labeler + ? sanitizeHandle(labeler.creator.handle, '@') + : label.src} + + + )} diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index ea1d975985..e3aec76316 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -1,5 +1,5 @@ -import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' import RNFS from 'react-native-fs' +import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' const GET_TIMEOUT = 15e3 // 15s const POST_TIMEOUT = 60e3 // 60s @@ -68,8 +68,10 @@ async function fetchHandler( resBody = jsonToLex(await res.json()) } else if (resMimeType.startsWith('text/')) { resBody = await res.text() + } else if (resMimeType === 'application/vnd.ipld.car') { + resBody = await res.arrayBuffer() } else { - throw new Error('TODO: non-textual response body') + throw new Error('Non-supported mime type') } } diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 85089608a7..3902a56599 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -71,6 +71,10 @@ export class FeedViewPostsSlice { ?.__source as ReasonFeedSource } + get feedContext() { + return this.items.find(item => item.feedContext)?.feedContext + } + containsUri(uri: string) { return !!this.items.find(item => item.post.uri === uri) } diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 9cd4abc626..71d5c701f8 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,12 +1,23 @@ import {Image as RNImage, Share as RNShare} from 'react-native' import {Image} from 'react-native-image-crop-picker' 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 Sharing from 'expo-sharing' import ImageResizer from '@bam.tech/react-native-image-resizer' +import {Buffer} from 'buffer' import RNFetchBlob from 'rn-fetch-blob' +import {logger} from '#/logger' import {isAndroid, isIOS} from 'platform/detection' import {Dimensions} from './types' @@ -240,3 +251,64 @@ function normalizePath(str: string, allPlatforms = false): string { } 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 { + // 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 +} diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 522aa2e51b..25315ebbd8 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,6 +1,7 @@ -import {Dimensions} from './types' 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( img: RNImage, @@ -138,3 +139,23 @@ function createResizedImage( 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() +} diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 1480696214..51793777ee 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -3,13 +3,18 @@ import {View} from 'react-native' import {TID} from '@atproto/common-web' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' import {useAnalytics} from '#/lib/analytics/analytics' import {BSKY_APP_ACCOUNT_DID, IS_PROD_SERVICE} from '#/lib/constants' import {DISCOVER_SAVED_FEED, TIMELINE_SAVED_FEED} from '#/lib/constants' import {logEvent, useGate} from '#/lib/statsig/statsig' 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 {useOnboardingDispatch} from '#/state/shell' import {uploadBlob} from 'lib/api' @@ -41,6 +46,7 @@ export function StepFinished() { const onboardDispatch = useOnboardingDispatch() const [saving, setSaving] = React.useState(false) const {mutateAsync: overwriteSavedFeeds} = useOverwriteSavedFeedsMutation() + const queryClient = useQueryClient() const {getAgent} = useAgent() const gate = useGate() @@ -112,33 +118,41 @@ export function StepFinished() { ]) } })(), - ]) - if (gate('reduced_onboarding_and_home_algo')) { - await getAgent().upsertProfile(async existing => { - existing = existing ?? {} - - if (profileStepResults.imageUri && profileStepResults.imageMime) { - const res = await uploadBlob( - getAgent(), - profileStepResults.imageUri, - profileStepResults.imageMime, - ) - - if (res.data.blob) { - existing.avatar = res.data.blob - } + (async () => { + const {imageUri, imageMime} = profileStepResults + if (imageUri && imageMime) { + const blobPromise = uploadBlob(getAgent(), imageUri, imageMime) + await getAgent().upsertProfile(async existing => { + existing = existing ?? {} + const res = await blobPromise + if (res.data.blob) { + existing.avatar = res.data.blob + } + return existing + }) } - - return existing - }) - } + })(), + ]) } catch (e: any) { logger.info(`onboarding: bulk save failed`) logger.error(e) // 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) dispatch({type: 'finish'}) onboardDispatch({type: 'finish'}) @@ -154,6 +168,7 @@ export function StepFinished() { track, getAgent, gate, + queryClient, ]) React.useEffect(() => { diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 7b312edfe5..e670e9da4a 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -303,7 +303,7 @@ export function usePostFeedQuery( i === 0 && slice.source ? slice.source : item.reason, - feedContext: item.feedContext, + feedContext: item.feedContext || slice.feedContext, moderation: moderations[i], } } diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index b3d2fa9ecd..555fd85a49 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -6,6 +6,7 @@ import { import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {track} from '#/lib/analytics/analytics' +import {PROD_DEFAULT_FEED} from '#/lib/constants' import {replaceEqualDeep} from '#/lib/functions' import {getAge} from '#/lib/strings/time' 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() { const queryClient = useQueryClient() const {getAgent} = useAgent() diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 103d34733c..3e25359166 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -6,6 +6,7 @@ import { AppBskyActorProfile, AtUri, BskyAgent, + ComAtprotoRepoUploadBlob, } from '@atproto/api' import { QueryClient, @@ -124,6 +125,26 @@ export function useProfileUpdateMutation() { newUserBanner, checkCommitted, }) => { + let newUserAvatarPromise: + | Promise + | undefined + if (newUserAvatar) { + newUserAvatarPromise = uploadBlob( + getAgent(), + newUserAvatar.path, + newUserAvatar.mime, + ) + } + let newUserBannerPromise: + | Promise + | undefined + if (newUserBanner) { + newUserBannerPromise = uploadBlob( + getAgent(), + newUserBanner.path, + newUserBanner.mime, + ) + } await getAgent().upsertProfile(async existing => { existing = existing || {} if (typeof updates === 'function') { @@ -132,22 +153,14 @@ export function useProfileUpdateMutation() { existing.displayName = updates.displayName existing.description = updates.description } - if (newUserAvatar) { - const res = await uploadBlob( - getAgent(), - newUserAvatar.path, - newUserAvatar.mime, - ) + if (newUserAvatarPromise) { + const res = await newUserAvatarPromise existing.avatar = res.data.blob } else if (newUserAvatar === null) { existing.avatar = undefined } - if (newUserBanner) { - const res = await uploadBlob( - getAgent(), - newUserBanner.path, - newUserBanner.mime, - ) + if (newUserBannerPromise) { + const res = await newUserBannerPromise existing.banner = res.data.blob } else if (newUserBanner === null) { existing.banner = undefined diff --git a/src/view/com/posts/FeedShutdownMsg.tsx b/src/view/com/posts/FeedShutdownMsg.tsx index bc047e8311..47f8941e2a 100644 --- a/src/view/com/posts/FeedShutdownMsg.tsx +++ b/src/view/com/posts/FeedShutdownMsg.tsx @@ -6,10 +6,9 @@ import {useLingui} from '@lingui/react' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {logger} from '#/logger' import { - useAddSavedFeedsMutation, usePreferencesQuery, useRemoveFeedMutation, - useUpdateSavedFeedsMutation, + useReplaceForYouWithDiscoverFeedMutation, } from '#/state/queries/preferences' import {useSetSelectedFeed} from '#/state/shell/selected-feed' import * as Toast from '#/view/com/util/Toast' @@ -24,12 +23,10 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) { const {_} = useLingui() const setSelectedFeed = useSetSelectedFeed() const {data: preferences} = usePreferencesQuery() - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = - useAddSavedFeedsMutation() const {mutateAsync: removeFeed, isPending: isRemovePending} = useRemoveFeedMutation() - const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = - useUpdateSavedFeedsMutation() + const {mutateAsync: replaceFeedWithDiscover, isPending: isReplacePending} = + useReplaceForYouWithDiscoverFeedMutation() const feedConfig = preferences?.savedFeeds?.find( f => f.value === feedUri && f.pinned, @@ -46,6 +43,9 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) { await removeFeed(feedConfig) Toast.show(_(msg`Removed from your feeds`)) } + if (hasDiscoverPinned) { + setSelectedFeed(`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`) + } } catch (err: any) { Toast.show( _( @@ -54,30 +54,15 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) { ) logger.error('Failed up update feeds', {message: err}) } - }, [removeFeed, feedConfig, _]) + }, [removeFeed, feedConfig, _, hasDiscoverPinned, setSelectedFeed]) const onReplaceFeed = React.useCallback(async () => { try { - if (!discoverFeedConfig) { - await addSavedFeeds([ - { - type: 'feed', - value: PROD_DEFAULT_FEED('whats-hot'), - pinned: true, - }, - ]) - } else { - await updateSavedFeeds([ - { - ...discoverFeedConfig, - pinned: true, - }, - ]) - } + await replaceFeedWithDiscover({ + forYouFeedConfig: feedConfig, + discoverFeedConfig, + }) setSelectedFeed(`feedgen|${PROD_DEFAULT_FEED('whats-hot')}`) - if (feedConfig) { - await removeFeed(feedConfig) - } Toast.show(_(msg`The feed has been replaced with Discover.`)) } catch (err: any) { Toast.show( @@ -88,17 +73,14 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) { logger.error('Failed up update feeds', {message: err}) } }, [ - addSavedFeeds, - updateSavedFeeds, - removeFeed, + replaceFeedWithDiscover, discoverFeedConfig, feedConfig, setSelectedFeed, _, ]) - const isProcessing = - isAddSavedFeedPending || isUpdateFeedPending || isRemovePending + const isProcessing = isReplacePending || isRemovePending return ( Replace with Discover - {(isAddSavedFeedPending || isUpdateFeedPending) && ( - - )} + {isReplacePending && } )} diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 0064a7b80c..90f5905f10 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -90,7 +90,7 @@ function ListImpl( }, { itemVisiblePercentThreshold: 40, - minimumViewTime: 2e3, + minimumViewTime: 1.5e3, }, ] }, [onItemSeen]) diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index a64f7acf31..df097bafab 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -27,7 +27,7 @@ export type ListProps = Omit< } export type ListRef = React.MutableRefObject // 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 = { rootMargin: '-200px 0px -200px 0px', } // post must be 200px visible to be "seen" diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx index 1b8d430b2a..af835cb620 100644 --- a/src/view/screens/Settings/ExportCarDialog.tsx +++ b/src/view/screens/Settings/ExportCarDialog.tsx @@ -3,12 +3,16 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgent, useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {saveBytesToDisk} from '#/lib/media/manip' +import {logger} from '#/logger' +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 {InlineLinkText, Link} from '#/components/Link' -import {P, Text} from '#/components/Typography' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' export function ExportCarDialog({ control, @@ -17,21 +21,35 @@ export function ExportCarDialog({ }) { const {_} = useLingui() const t = useTheme() - const {gtMobile} = useBreakpoints() - const {currentAccount} = useSession() const {getAgent} = useAgent() + const [loading, setLoading] = React.useState(false) - const downloadUrl = React.useMemo(() => { + const download = React.useCallback(async () => { const agent = getAgent() - if (!currentAccount || !agent.session) { - return '' // shouldnt ever happen + if (!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, getAgent]) + try { + setLoading(true) + const did = agent.session.did + const downloadRes = await agent.com.atproto.sync.getRepo({did}) + const saveRes = await saveBytesToDisk( + '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 ( @@ -40,34 +58,34 @@ export function ExportCarDialog({ - + Export My Data -

+ 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. -

+ - + disabled={loading} + onPress={download}> Download CAR file - + {loading && } + -

. -

- - - - - - {!gtMobile && } +
diff --git a/yarn.lock b/yarn.lock index 6df2993f4a..1e53b30624 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,10 +58,10 @@ multiformats "^9.9.0" tlds "^1.234.0" -"@atproto/api@^0.12.6": - version "0.12.6" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.6.tgz#690c004c5ac7fc7bceac4605d8c1ec1f580be270" - integrity sha512-30htXN2Hjl1jzzeAtIhggOsVS4vA975pMUQYoA4xMonug+z6O9NHcka3yYb4C9ldpnGugvRPKH7EhAUbiDTC5w== +"@atproto/api@^0.12.9": + version "0.12.9" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.9.tgz#5ae040980e574a5d9496368c4ca032c0cda174ec" + integrity sha512-3D4n2ZAAsDRnjevvcoIxQxuMMoqc+7vtVyP7EnrEdeOmRSCF9j8yXTqhn6rcHCbzcs3DKyYR26nQemtZsMsE0g== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0"