Add "follows you" information and sync follow state between views (#215)

* Bump @atproto/api@0.1.2 and update API usage

* Add 'follows you' pill to profile header (close #110)

* Add 'follows you' to followers and follows (close #103)

* Update reposted-by and liked-by views to use the same components as followers and following

* Create a local follows cache MyFollowsModel to keep views in sync (close #205)

* Add incremental hydration to the MyFollows model

* Fix tests

* Update deps

* Fix lint

* Fix to paginated fetches

* Fix reference

* Fix potential state-desync issue
This commit is contained in:
Paul Frazee
2023-02-16 14:34:04 -06:00
committed by GitHub
parent ad4ccade51
commit 70ec76970c
19 changed files with 370 additions and 515 deletions
+8 -3
View File
@@ -6,6 +6,7 @@ import {SessionModel} from '../src/state/models/session'
import {NavigationModel} from '../src/state/models/navigation' import {NavigationModel} from '../src/state/models/navigation'
import {ShellUiModel} from '../src/state/models/shell-ui' import {ShellUiModel} from '../src/state/models/shell-ui'
import {MeModel} from '../src/state/models/me' import {MeModel} from '../src/state/models/me'
import {MyFollowsModel} from '../src/state/models/my-follows'
import {OnboardModel} from '../src/state/models/onboard' import {OnboardModel} from '../src/state/models/onboard'
import {ProfilesViewModel} from '../src/state/models/profiles-view' import {ProfilesViewModel} from '../src/state/models/profiles-view'
import {LinkMetasViewModel} from '../src/state/models/link-metas-view' import {LinkMetasViewModel} from '../src/state/models/link-metas-view'
@@ -53,9 +54,8 @@ export const mockedProfileStore = {
followsCount: 0, followsCount: 0,
membersCount: 0, membersCount: 0,
postsCount: 0, postsCount: 0,
myState: { viewer: {
follow: '', following: '',
member: '',
}, },
rootStore: {} as RootStoreModel, rootStore: {} as RootStoreModel,
hasContent: true, hasContent: true,
@@ -572,6 +572,10 @@ export const mockedShellStore = {
openLightbox: jest.fn(), openLightbox: jest.fn(),
} as ShellUiModel } as ShellUiModel
export const mockedFollowsStore = {
isFollowing: jest.fn().mockReturnValue(false),
} as MyFollowsModel
export const mockedMeStore = { export const mockedMeStore = {
serialize: jest.fn(), serialize: jest.fn(),
hydrate: jest.fn(), hydrate: jest.fn(),
@@ -585,6 +589,7 @@ export const mockedMeStore = {
memberships: mockedMembershipsStore, memberships: mockedMembershipsStore,
mainFeed: mockedFeedStore, mainFeed: mockedFeedStore,
notifications: mockedNotificationsStore, notifications: mockedNotificationsStore,
follows: mockedFollowsStore,
clear: jest.fn(), clear: jest.fn(),
load: jest.fn(), load: jest.fn(),
clearNotificationCount: jest.fn(), clearNotificationCount: jest.fn(),
+2 -2
View File
@@ -16,7 +16,7 @@
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all" "e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.1.1", "@atproto/api": "^0.1.2",
"@atproto/lexicon": "^0.0.4", "@atproto/lexicon": "^0.0.4",
"@atproto/xrpc": "^0.0.4", "@atproto/xrpc": "^0.0.4",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
@@ -80,7 +80,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/pds": "^0.0.2", "@atproto/pds": "^0.0.3",
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0", "@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
+6
View File
@@ -3,6 +3,7 @@ import notifee from '@notifee/react-native'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {FeedModel} from './feed-view' import {FeedModel} from './feed-view'
import {NotificationsViewModel} from './notifications-view' import {NotificationsViewModel} from './notifications-view'
import {MyFollowsModel} from './my-follows'
import {isObj, hasProp} from '../lib/type-guards' import {isObj, hasProp} from '../lib/type-guards'
import {displayNotificationFromModel} from '../../view/lib/notifee' import {displayNotificationFromModel} from '../../view/lib/notifee'
@@ -15,6 +16,7 @@ export class MeModel {
notificationCount: number = 0 notificationCount: number = 0
mainFeed: FeedModel mainFeed: FeedModel
notifications: NotificationsViewModel notifications: NotificationsViewModel
follows: MyFollowsModel
constructor(public rootStore: RootStoreModel) { constructor(public rootStore: RootStoreModel) {
makeAutoObservable( makeAutoObservable(
@@ -26,6 +28,7 @@ export class MeModel {
algorithm: 'reverse-chronological', algorithm: 'reverse-chronological',
}) })
this.notifications = new NotificationsViewModel(this.rootStore, {}) this.notifications = new NotificationsViewModel(this.rootStore, {})
this.follows = new MyFollowsModel(this.rootStore)
} }
clear() { clear() {
@@ -104,6 +107,9 @@ export class MeModel {
this.notifications.setup().catch(e => { this.notifications.setup().catch(e => {
this.rootStore.log.error('Failed to setup notifications model', e) this.rootStore.log.error('Failed to setup notifications model', e)
}), }),
this.follows.fetch().catch(e => {
this.rootStore.log.error('Failed to load my follows', e)
}),
]) ])
// request notifications permission once the user has logged in // request notifications permission once the user has logged in
+109
View File
@@ -0,0 +1,109 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {FollowRecord, AppBskyActorProfile, AppBskyActorRef} from '@atproto/api'
import {RootStoreModel} from './root-store'
import {bundleAsync} from '../../lib/async/bundle'
const CACHE_TTL = 1000 * 60 * 60 // hourly
type FollowsListResponse = Awaited<ReturnType<FollowRecord['list']>>
type FollowsListResponseRecord = FollowsListResponse['records'][0]
type Profile =
| AppBskyActorProfile.ViewBasic
| AppBskyActorProfile.View
| AppBskyActorRef.WithInfo
/**
* This model is used to maintain a synced local cache of the user's
* follows. It should be periodically refreshed and updated any time
* the user makes a change to their follows.
*/
export class MyFollowsModel {
// data
followDidToRecordMap: Record<string, string> = {}
lastSync = 0
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// public api
// =
fetchIfNeeded = bundleAsync(async () => {
if (
Object.keys(this.followDidToRecordMap).length === 0 ||
Date.now() - this.lastSync > CACHE_TTL
) {
return await this.fetch()
}
})
fetch = bundleAsync(async () => {
this.rootStore.log.debug('MyFollowsModel:fetch running full fetch')
let before
let records: FollowsListResponseRecord[] = []
do {
const res: FollowsListResponse =
await this.rootStore.api.app.bsky.graph.follow.list({
user: this.rootStore.me.did,
before,
})
records = records.concat(res.records)
before = res.cursor
} while (typeof before !== 'undefined')
runInAction(() => {
this.followDidToRecordMap = {}
for (const record of records) {
this.followDidToRecordMap[record.value.subject.did] = record.uri
}
this.lastSync = Date.now()
})
})
isFollowing(did: string) {
return !!this.followDidToRecordMap[did]
}
getFollowUri(did: string): string {
const v = this.followDidToRecordMap[did]
if (!v) {
throw new Error('Not a followed user')
}
return v
}
addFollow(did: string, recordUri: string) {
this.followDidToRecordMap[did] = recordUri
}
removeFollow(did: string) {
delete this.followDidToRecordMap[did]
}
/**
* Use this to incrementally update the cache as views provide information
*/
hydrate(did: string, recordUri: string | undefined) {
if (recordUri) {
this.followDidToRecordMap[did] = recordUri
} else {
delete this.followDidToRecordMap[did]
}
}
/**
* Use this to incrementally update the cache as views provide information
*/
hydrateProfiles(profiles: Profile[]) {
for (const profile of profiles) {
if (profile.viewer) {
this.hydrate(profile.did, profile.viewer.following)
}
}
}
}
+27 -11
View File
@@ -15,9 +15,10 @@ import {cleanError} from '../../lib/strings'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser' export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
export class ProfileViewMyStateModel { export class ProfileViewViewerModel {
follow?: string
muted?: boolean muted?: boolean
following?: string
followedBy?: string
constructor() { constructor() {
makeAutoObservable(this) makeAutoObservable(this)
@@ -47,7 +48,7 @@ export class ProfileViewModel {
followersCount: number = 0 followersCount: number = 0
followsCount: number = 0 followsCount: number = 0
postsCount: number = 0 postsCount: number = 0
myState = new ProfileViewMyStateModel() viewer = new ProfileViewViewerModel()
// added data // added data
descriptionEntities?: Entity[] descriptionEntities?: Entity[]
@@ -98,11 +99,24 @@ export class ProfileViewModel {
if (!this.rootStore.me.did) { if (!this.rootStore.me.did) {
throw new Error('Not logged in') throw new Error('Not logged in')
} }
if (this.myState.follow) {
await apilib.unfollow(this.rootStore, this.myState.follow) const follows = this.rootStore.me.follows
const followUri = follows.isFollowing(this.did)
? follows.getFollowUri(this.did)
: undefined
// guard against this view getting out of sync with the follows cache
if (followUri !== this.viewer.following) {
this.viewer.following = followUri
return
}
if (followUri) {
await apilib.unfollow(this.rootStore, followUri)
runInAction(() => { runInAction(() => {
this.followersCount-- this.followersCount--
this.myState.follow = undefined this.viewer.following = undefined
this.rootStore.me.follows.removeFollow(this.did)
}) })
} else { } else {
const res = await apilib.follow( const res = await apilib.follow(
@@ -112,7 +126,8 @@ export class ProfileViewModel {
) )
runInAction(() => { runInAction(() => {
this.followersCount++ this.followersCount++
this.myState.follow = res.uri this.viewer.following = res.uri
this.rootStore.me.follows.addFollow(this.did, res.uri)
}) })
} }
} }
@@ -153,13 +168,13 @@ export class ProfileViewModel {
async muteAccount() { async muteAccount() {
await this.rootStore.api.app.bsky.graph.mute({user: this.did}) await this.rootStore.api.app.bsky.graph.mute({user: this.did})
this.myState.muted = true this.viewer.muted = true
await this.refresh() await this.refresh()
} }
async unmuteAccount() { async unmuteAccount() {
await this.rootStore.api.app.bsky.graph.unmute({user: this.did}) await this.rootStore.api.app.bsky.graph.unmute({user: this.did})
this.myState.muted = false this.viewer.muted = false
await this.refresh() await this.refresh()
} }
@@ -211,8 +226,9 @@ export class ProfileViewModel {
this.followersCount = res.data.followersCount this.followersCount = res.data.followersCount
this.followsCount = res.data.followsCount this.followsCount = res.data.followsCount
this.postsCount = res.data.postsCount this.postsCount = res.data.postsCount
if (res.data.myState) { if (res.data.viewer) {
Object.assign(this.myState, res.data.myState) Object.assign(this.viewer, res.data.viewer)
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
} }
this.descriptionEntities = extractEntities(this.description || '') this.descriptionEntities = extractEntities(this.description || '')
} }
+6 -2
View File
@@ -1,6 +1,9 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' import {
AppBskyFeedGetRepostedBy as GetRepostedBy,
AppBskyActorRef as ActorRef,
} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {bundleAsync} from '../../lib/async/bundle' import {bundleAsync} from '../../lib/async/bundle'
import {cleanError} from '../../lib/strings' import {cleanError} from '../../lib/strings'
@@ -8,7 +11,7 @@ import * as apilib from '../lib/api'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type RepostedByItem = GetRepostedBy.RepostedBy export type RepostedByItem = ActorRef.WithInfo
export class RepostedByViewModel { export class RepostedByViewModel {
// state // state
@@ -127,5 +130,6 @@ export class RepostedByViewModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.repostedBy = this.repostedBy.concat(res.data.repostedBy) this.repostedBy = this.repostedBy.concat(res.data.repostedBy)
this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy)
} }
} }
+1
View File
@@ -153,6 +153,7 @@ export class RootStoreModel {
} }
try { try {
await this.me.fetchNotifications() await this.me.fetchNotifications()
await this.me.follows.fetchIfNeeded()
} catch (e: any) { } catch (e: any) {
this.log.error('Failed to fetch latest state', e) this.log.error('Failed to fetch latest state', e)
} }
+11 -9
View File
@@ -1,8 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import { import {AppBskyActorProfile as Profile} from '@atproto/api'
AppBskyActorGetSuggestions as GetSuggestions,
AppBskyActorProfile as Profile,
} from '@atproto/api'
import {RootStoreModel} from './root-store' import {RootStoreModel} from './root-store'
import {cleanError} from '../../lib/strings' import {cleanError} from '../../lib/strings'
import {bundleAsync} from '../../lib/async/bundle' import {bundleAsync} from '../../lib/async/bundle'
@@ -14,7 +11,7 @@ import {
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type SuggestedActor = GetSuggestions.Actor | Profile.View export type SuggestedActor = Profile.ViewBasic | Profile.View
const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => { const getSuggestionList = ({serviceUrl}: {serviceUrl: string}) => {
if (serviceUrl.includes('localhost')) { if (serviceUrl.includes('localhost')) {
@@ -142,10 +139,15 @@ export class SuggestedActorsViewModel {
} while (actors.length) } while (actors.length)
runInAction(() => { runInAction(() => {
this.hardCodedSuggestions = profiles.filter( this.hardCodedSuggestions = profiles.filter(profile => {
profile => if (this.rootStore.me.follows.isFollowing(profile.did)) {
!profile.myState?.follow && profile.did !== this.rootStore.me.did, return false
) }
if (profile.did === this.rootStore.me.did) {
return false
}
return true
})
}) })
} catch (e) { } catch (e) {
this.rootStore.log.error( this.rootStore.log.error(
+2 -1
View File
@@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type FollowerItem = GetFollowers.Follower export type FollowerItem = ActorRef.WithInfo
export class UserFollowersViewModel { export class UserFollowersViewModel {
// state // state
@@ -116,5 +116,6 @@ export class UserFollowersViewModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.followers = this.followers.concat(res.data.followers) this.followers = this.followers.concat(res.data.followers)
this.rootStore.me.follows.hydrateProfiles(res.data.followers)
} }
} }
+2 -1
View File
@@ -9,7 +9,7 @@ import {bundleAsync} from '../../lib/async/bundle'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
export type FollowItem = GetFollows.Follow export type FollowItem = ActorRef.WithInfo
export class UserFollowsViewModel { export class UserFollowsViewModel {
// state // state
@@ -116,5 +116,6 @@ export class UserFollowsViewModel {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
this.follows = this.follows.concat(res.data.follows) this.follows = this.follows.concat(res.data.follows)
this.rootStore.me.follows.hydrateProfiles(res.data.follows)
} }
} }
+14 -179
View File
@@ -1,47 +1,27 @@
import React, {useEffect, useState} from 'react' import React from 'react'
import { import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
ActivityIndicator,
FlatList,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import {ErrorScreen} from '../util/error/ErrorScreen' import {ErrorScreen} from '../util/error/ErrorScreen'
import {Link} from '../util/Link' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
import { import {
SuggestedActorsViewModel, SuggestedActorsViewModel,
SuggestedActor, SuggestedActor,
} from '../../../state/models/suggested-actors-view' } from '../../../state/models/suggested-actors-view'
import {s, gradients} from '../../lib/styles' import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
export const SuggestedFollows = observer( export const SuggestedFollows = observer(
({ ({onNoSuggestions}: {onNoSuggestions?: () => void}) => {
onNoSuggestions,
asLinks,
}: {
onNoSuggestions?: () => void
asLinks?: boolean
}) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [follows, setFollows] = useState<Record<string, string>>({})
const view = React.useMemo<SuggestedActorsViewModel>( const view = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store), () => new SuggestedActorsViewModel(store),
[store], [store],
) )
useEffect(() => { React.useEffect(() => {
view view
.loadMore() .loadMore()
.catch((err: any) => .catch((err: any) =>
@@ -49,7 +29,7 @@ export const SuggestedFollows = observer(
) )
}, [view, store.log]) }, [view, store.log])
useEffect(() => { React.useEffect(() => {
if (!view.isLoading && !view.hasError && !view.hasContent) { if (!view.isLoading && !view.hasError && !view.hasContent) {
onNoSuggestions?.() onNoSuggestions?.()
} }
@@ -70,48 +50,16 @@ export const SuggestedFollows = observer(
) )
} }
const onPressFollow = async (item: SuggestedActor) => {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const onPressUnfollow = async (item: SuggestedActor) => {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
}
const renderItem = ({item}: {item: SuggestedActor}) => { const renderItem = ({item}: {item: SuggestedActor}) => {
if (asLinks) {
return ( return (
<Link <ProfileCardWithFollowBtn
key={item.did} key={item.did}
href={`/profile/${item.handle}`} did={item.did}
title={item.displayName || item.handle}> declarationCid={item.declaration.cid}
<User handle={item.handle}
item={item} displayName={item.displayName}
follow={follows[item.did]} avatar={item.avatar}
onPressFollow={onPressFollow} description={item.description}
onPressUnfollow={onPressUnfollow}
/>
</Link>
)
}
return (
<User
key={item.did}
item={item}
follow={follows[item.did]}
onPressFollow={onPressFollow}
onPressUnfollow={onPressUnfollow}
/> />
) )
} }
@@ -150,75 +98,6 @@ export const SuggestedFollows = observer(
}, },
) )
const User = ({
item,
follow,
onPressFollow,
onPressUnfollow,
}: {
item: SuggestedActor
follow: string | undefined
onPressFollow: (item: SuggestedActor) => void
onPressUnfollow: (item: SuggestedActor) => void
}) => {
const pal = usePalette('default')
return (
<View style={[styles.actor, pal.view, pal.border]}>
<View style={styles.actorMeta}>
<View style={styles.actorAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.actorContent}>
<Text type="title-sm" style={pal.text} numberOfLines={1}>
{item.displayName || item.handle}
</Text>
<Text style={pal.textLight} numberOfLines={1}>
@{item.handle}
</Text>
</View>
<View style={styles.actorBtn}>
{follow ? (
<TouchableOpacity onPress={() => onPressUnfollow(item)}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<Text type="button" style={pal.text}>
Unfollow
</Text>
</View>
</TouchableOpacity>
) : (
<TouchableOpacity onPress={() => onPressFollow(item)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn, styles.gradientBtn]}>
<FontAwesomeIcon
icon="plus"
style={[s.white, s.mr5]}
size={15}
/>
<Text style={[s.white, s.fw600, s.f15]}>Follow</Text>
</LinearGradient>
</TouchableOpacity>
)}
</View>
</View>
{item.description ? (
<View style={styles.actorDetails}>
<Text style={pal.text} numberOfLines={4}>
{item.description}
</Text>
</View>
) : undefined}
</View>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
height: '100%', height: '100%',
@@ -231,48 +110,4 @@ const styles = StyleSheet.create({
height: 200, height: 200,
paddingTop: 20, paddingTop: 20,
}, },
actor: {
borderTopWidth: 1,
paddingHorizontal: 6,
},
actorMeta: {
flexDirection: 'row',
},
actorAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
actorContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
},
actorBtn: {
paddingRight: 10,
paddingTop: 10,
},
actorDetails: {
paddingLeft: 60,
paddingRight: 10,
paddingBottom: 10,
},
gradientBtn: {
paddingHorizontal: 24,
paddingVertical: 6,
},
secondaryBtn: {
paddingHorizontal: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
},
}) })
+6 -65
View File
@@ -6,23 +6,16 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import _omit from 'lodash.omit'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import { import {SuggestedActorsViewModel} from '../../../state/models/suggested-actors-view'
SuggestedActorsViewModel, import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
SuggestedActor,
} from '../../../state/models/suggested-actors-view'
import * as apilib from '../../../state/lib/api'
import {s} from '../../lib/styles'
import {ProfileCard} from '../profile/ProfileCard'
import * as Toast from '../util/Toast'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
export const WhoToFollow = observer(() => { export const WhoToFollow = observer(() => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [follows, setFollows] = React.useState<Record<string, string>>({})
const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>( const suggestedActorsView = React.useMemo<SuggestedActorsViewModel>(
() => new SuggestedActorsViewModel(store, {pageSize: 5}), () => new SuggestedActorsViewModel(store, {pageSize: 5}),
[store], [store],
@@ -35,25 +28,6 @@ export const WhoToFollow = observer(() => {
const onPressLoadMoreSuggestedActors = () => { const onPressLoadMoreSuggestedActors = () => {
suggestedActorsView.loadMore() suggestedActorsView.loadMore()
} }
const onToggleFollow = async (item: SuggestedActor) => {
if (follows[item.did]) {
try {
await apilib.unfollow(store, follows[item.did])
setFollows(_omit(follows, [item.did]))
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else {
try {
const res = await apilib.follow(store, item.did, item.declaration.cid)
setFollows({[item.did]: res.uri, ...follows})
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
}
return ( return (
<> <>
{(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && ( {(suggestedActorsView.hasContent || suggestedActorsView.isLoading) && (
@@ -65,18 +39,14 @@ export const WhoToFollow = observer(() => {
<> <>
<View style={[pal.border, styles.bottomBorder]}> <View style={[pal.border, styles.bottomBorder]}>
{suggestedActorsView.suggestions.map(item => ( {suggestedActorsView.suggestions.map(item => (
<ProfileCard <ProfileCardWithFollowBtn
key={item.did} key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle} handle={item.handle}
displayName={item.displayName} displayName={item.displayName}
avatar={item.avatar} avatar={item.avatar}
description={item.description} description={item.description}
renderButton={() => (
<FollowBtn
isFollowing={!!follows[item.did]}
onPress={() => onToggleFollow(item)}
/>
)}
/> />
))} ))}
</View> </View>
@@ -100,25 +70,6 @@ export const WhoToFollow = observer(() => {
) )
}) })
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={[pal.text]}>
{isFollowing ? 'Unfollow' : 'Follow'}
</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
heading: { heading: {
fontWeight: 'bold', fontWeight: 'bold',
@@ -135,14 +86,4 @@ const styles = StyleSheet.create({
paddingLeft: 16, paddingLeft: 16,
paddingVertical: 12, paddingVertical: 12,
}, },
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
}) })
+10 -55
View File
@@ -5,12 +5,9 @@ import {
RepostedByViewModel, RepostedByViewModel,
RepostedByItem, RepostedByItem,
} from '../../../state/models/reposted-by-view' } from '../../../state/models/reposted-by-view'
import {UserAvatar} from '../util/UserAvatar' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles'
export const PostRepostedBy = observer(function PostRepostedBy({ export const PostRepostedBy = observer(function PostRepostedBy({
uri, uri,
@@ -61,7 +58,15 @@ export const PostRepostedBy = observer(function PostRepostedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: RepostedByItem}) => ( const renderItem = ({item}: {item: RepostedByItem}) => (
<RepostedByItemCom item={item} /> <ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList
@@ -82,57 +87,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
) )
}) })
const RepostedByItemCom = ({item}: {item: RepostedByItem}) => {
return (
<Link
style={styles.outer}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold]}>{item.displayName || item.handle}</Text>
<Text style={[s.f14, s.gray5]}>@{item.handle}</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: {
marginTop: 1,
backgroundColor: colors.white,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 20,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: { footer: {
height: 200, height: 200,
paddingTop: 20, paddingTop: 20,
+13 -62
View File
@@ -1,14 +1,10 @@
import React, {useEffect} from 'react' import React, {useEffect} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native'
import {VotesViewModel, VotesItem} from '../../../state/models/votes-view' import {VotesViewModel, VoteItem} from '../../../state/models/votes-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
export const PostVotedBy = observer(function PostVotedBy({ export const PostVotedBy = observer(function PostVotedBy({
uri, uri,
@@ -56,7 +52,17 @@ export const PostVotedBy = observer(function PostVotedBy({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: VotesItem}) => <LikedByItem item={item} /> const renderItem = ({item}: {item: VoteItem}) => (
<ProfileCardWithFollowBtn
key={item.actor.did}
did={item.actor.did}
declarationCid={item.actor.declaration.cid}
handle={item.actor.handle}
displayName={item.actor.displayName}
avatar={item.actor.avatar}
isFollowedBy={!!item.actor.viewer?.followedBy}
/>
)
return ( return (
<FlatList <FlatList
data={view.votes} data={view.votes}
@@ -76,62 +82,7 @@ export const PostVotedBy = observer(function PostVotedBy({
) )
}) })
const LikedByItem = ({item}: {item: VotesItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view]}
href={`/profile/${item.actor.handle}`}
title={item.actor.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.actor.displayName}
handle={item.actor.handle}
avatar={item.actor.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold, pal.text]}>
{item.actor.displayName || item.actor.handle}
</Text>
<Text style={[s.f14, s.gray5, pal.textLight]}>
@{item.actor.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: {
marginTop: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 20,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: { footer: {
height: 200, height: 200,
paddingTop: 20, paddingTop: 20,
+105 -3
View File
@@ -1,22 +1,28 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import * as Toast from '../util/Toast'
import {s} from '../../lib/styles' import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from '../../../state'
import * as apilib from '../../../state/lib/api'
export function ProfileCard({ export function ProfileCard({
handle, handle,
displayName, displayName,
avatar, avatar,
description, description,
isFollowedBy,
renderButton, renderButton,
}: { }: {
handle: string handle: string
displayName?: string displayName?: string
avatar?: string avatar?: string
description?: string description?: string
isFollowedBy?: boolean
renderButton?: () => JSX.Element renderButton?: () => JSX.Element
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
@@ -36,12 +42,19 @@ export function ProfileCard({
/> />
</View> </View>
<View style={styles.layoutContent}> <View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]} numberOfLines={1}> <Text type="lg" style={[s.bold, pal.text]} numberOfLines={1}>
{displayName || handle} {displayName || handle}
</Text> </Text>
<Text type="sm" style={[pal.textLight]} numberOfLines={1}> <Text type="md" style={[pal.textLight]} numberOfLines={1}>
@{handle} @{handle}
</Text> </Text>
{isFollowedBy && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs">Follows You</Text>
</View>
</View>
)}
</View> </View>
{renderButton ? ( {renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View> <View style={styles.layoutButton}>{renderButton()}</View>
@@ -58,6 +71,84 @@ export function ProfileCard({
) )
} }
export const ProfileCardWithFollowBtn = observer(
({
did,
declarationCid,
handle,
displayName,
avatar,
description,
isFollowedBy,
}: {
did: string
declarationCid: string
handle: string
displayName?: string
avatar?: string
description?: string
isFollowedBy?: boolean
}) => {
const store = useStores()
const isMe = store.me.handle === handle
const isFollowing = store.me.follows.isFollowing(did)
const onToggleFollow = async () => {
if (store.me.follows.isFollowing(did)) {
try {
await apilib.unfollow(store, store.me.follows.getFollowUri(did))
store.me.follows.removeFollow(did)
} catch (e: any) {
store.log.error('Failed fo delete follow', e)
Toast.show('An issue occurred, please try again.')
}
} else {
try {
const res = await apilib.follow(store, did, declarationCid)
store.me.follows.addFollow(did, res.uri)
} catch (e: any) {
store.log.error('Failed fo create follow', e)
Toast.show('An issue occurred, please try again.')
}
}
}
return (
<ProfileCard
handle={handle}
displayName={displayName}
avatar={avatar}
description={description}
isFollowedBy={isFollowedBy}
renderButton={
isMe
? undefined
: () => (
<FollowBtn isFollowing={isFollowing} onPress={onToggleFollow} />
)
}
/>
)
},
)
function FollowBtn({
isFollowing,
onPress,
}: {
isFollowing: boolean
onPress: () => void
}) {
const pal = usePalette('default')
return (
<TouchableOpacity onPress={onPress}>
<View style={[styles.btn, pal.btn]}>
<Text type="button" style={[pal.text]}>
{isFollowing ? 'Unfollow' : 'Follow'}
</Text>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: { outer: {
borderTopWidth: 1, borderTopWidth: 1,
@@ -93,4 +184,15 @@ const styles = StyleSheet.create({
paddingRight: 10, paddingRight: 10,
paddingBottom: 10, paddingBottom: 10,
}, },
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
btn: {
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
}) })
+10 -54
View File
@@ -5,13 +5,9 @@ import {
UserFollowersViewModel, UserFollowersViewModel,
FollowerItem, FollowerItem,
} from '../../../state/models/user-followers-view' } from '../../../state/models/user-followers-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
export const ProfileFollowers = observer(function ProfileFollowers({ export const ProfileFollowers = observer(function ProfileFollowers({
name, name,
@@ -62,7 +58,15 @@ export const ProfileFollowers = observer(function ProfileFollowers({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowerItem}) => ( const renderItem = ({item}: {item: FollowerItem}) => (
<User key={item.did} item={item} /> <ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList
@@ -83,55 +87,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
) )
}) })
const User = ({item}: {item: FollowerItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: { footer: {
height: 200, height: 200,
paddingTop: 20, paddingTop: 20,
+10 -54
View File
@@ -5,13 +5,9 @@ import {
UserFollowsViewModel, UserFollowsViewModel,
FollowItem, FollowItem,
} from '../../../state/models/user-follows-view' } from '../../../state/models/user-follows-view'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {UserAvatar} from '../util/UserAvatar' import {ProfileCardWithFollowBtn} from './ProfileCard'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {usePalette} from '../../lib/hooks/usePalette'
export const ProfileFollows = observer(function ProfileFollows({ export const ProfileFollows = observer(function ProfileFollows({
name, name,
@@ -62,7 +58,15 @@ export const ProfileFollows = observer(function ProfileFollows({
// loaded // loaded
// = // =
const renderItem = ({item}: {item: FollowItem}) => ( const renderItem = ({item}: {item: FollowItem}) => (
<User key={item.did} item={item} /> <ProfileCardWithFollowBtn
key={item.did}
did={item.did}
declarationCid={item.declaration.cid}
handle={item.handle}
displayName={item.displayName}
avatar={item.avatar}
isFollowedBy={!!item.viewer?.followedBy}
/>
) )
return ( return (
<FlatList <FlatList
@@ -83,55 +87,7 @@ export const ProfileFollows = observer(function ProfileFollows({
) )
}) })
const User = ({item}: {item: FollowItem}) => {
const pal = usePalette('default')
return (
<Link
style={[styles.outer, pal.view, pal.border]}
href={`/profile/${item.handle}`}
title={item.handle}
noFeedback>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar
size={40}
displayName={item.displayName}
handle={item.handle}
avatar={item.avatar}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.bold, pal.text]}>
{item.displayName || item.handle}
</Text>
<Text type="sm" style={[pal.textLight]}>
@{item.handle}
</Text>
</View>
</View>
</Link>
)
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
footer: { footer: {
height: 200, height: 200,
paddingTop: 20, paddingTop: 20,
+19 -6
View File
@@ -51,7 +51,7 @@ export const ProfileHeader = observer(function ProfileHeader({
view?.toggleFollowing().then( view?.toggleFollowing().then(
() => { () => {
Toast.show( Toast.show(
`${view.myState.follow ? 'Following' : 'No longer following'} ${ `${view.viewer.following ? 'Following' : 'No longer following'} ${
view.displayName || view.handle view.displayName || view.handle
}`, }`,
) )
@@ -140,8 +140,8 @@ export const ProfileHeader = observer(function ProfileHeader({
let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}] let dropdownItems: DropdownItem[] = [{label: 'Share', onPress: onPressShare}]
if (!isMe) { if (!isMe) {
dropdownItems.push({ dropdownItems.push({
label: view.myState.muted ? 'Unmute Account' : 'Mute Account', label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
onPress: view.myState.muted ? onPressUnmuteAccount : onPressMuteAccount, onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
}) })
dropdownItems.push({ dropdownItems.push({
label: 'Report Account', label: 'Report Account',
@@ -164,7 +164,7 @@ export const ProfileHeader = observer(function ProfileHeader({
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<> <>
{view.myState.follow ? ( {store.me.follows.isFollowing(view.did) ? (
<TouchableOpacity <TouchableOpacity
onPress={onPressToggleFollow} onPress={onPressToggleFollow}
style={[styles.btn, styles.mainBtn, pal.btn]}> style={[styles.btn, styles.mainBtn, pal.btn]}>
@@ -213,6 +213,13 @@ export const ProfileHeader = observer(function ProfileHeader({
</Text> </Text>
</View> </View>
<View style={styles.handleLine}> <View style={styles.handleLine}>
{view.viewer.followedBy ? (
<View style={[styles.pill, pal.btn, s.mr5]}>
<Text type="xs" style={[pal.text]}>
Follows you
</Text>
</View>
) : undefined}
<Text style={pal.textLight}>@{view.handle}</Text> <Text style={pal.textLight}>@{view.handle}</Text>
</View> </View>
<View style={styles.metricsLine}> <View style={styles.metricsLine}>
@@ -257,14 +264,14 @@ export const ProfileHeader = observer(function ProfileHeader({
entities={view.descriptionEntities} entities={view.descriptionEntities}
/> />
) : undefined} ) : undefined}
{view.myState.muted ? ( {view.viewer.muted ? (
<View style={[styles.detailLine, pal.btn, s.p5]}> <View style={[styles.detailLine, pal.btn, s.p5]}>
<FontAwesomeIcon <FontAwesomeIcon
icon={['far', 'eye-slash']} icon={['far', 'eye-slash']}
style={[pal.text, s.mr5]} style={[pal.text, s.mr5]}
/> />
<Text type="md" style={[s.mr2, pal.text]}> <Text type="md" style={[s.mr2, pal.text]}>
Account muted. Account muted
</Text> </Text>
</View> </View>
) : undefined} ) : undefined}
@@ -371,6 +378,12 @@ const styles = StyleSheet.create({
marginBottom: 5, marginBottom: 5,
}, },
pill: {
borderRadius: 4,
paddingHorizontal: 6,
paddingVertical: 2,
},
br40: {borderRadius: 40}, br40: {borderRadius: 40},
br50: {borderRadius: 50}, br50: {borderRadius: 50},
}) })
+9 -8
View File
@@ -19,10 +19,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.1.1": "@atproto/api@^0.1.2":
version "0.1.1" version "0.1.2"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.1.tgz#0215dd80b98b8698f9e08932d4cdaf52676cb56c" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.2.tgz#66102f9203ba499432bc5aeb30cd19313ab2e4fc"
integrity sha512-OMKLXuWxsdaHmmZ8XzwZ1uzWpq1sr9cuzZJLAbWJz0HRDQ3a0tg1uI80XvDYHFep0qgVoT2OKJNtB23Wp7nKoQ== integrity sha512-lDcFGkrk0J7rkIPSie18xS7sO3IL6DsosX8GgoeqCNeVaDuphBRaFCcpBUWf0q4fHrpmdgghGo4ulefyKHTIFQ==
dependencies: dependencies:
"@atproto/xrpc" "*" "@atproto/xrpc" "*"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
@@ -90,10 +90,10 @@
resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4" resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw== integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
"@atproto/pds@^0.0.2": "@atproto/pds@^0.0.3":
version "0.0.2" version "0.0.3"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.0.2.tgz#3665a24ce1f3a5696e46fea5448c5f849e38b7e0" resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.0.3.tgz#118a1d51687664f085f8e1c19ae3ac1646dc69b2"
integrity sha512-TCTVKJWaUxF6EQJ6hobhO19bLaoW2Nk8xn/mySnQMN7ZHlYD9Hv+eRVY+PWLpnAuGetCkTbZAspg84eG2lKGTA== integrity sha512-l5iGJNyQs73V/mQWkcg4NXNGbnfXfv+Yg3g8nqwtun409iAAeYZimA1Tt0JV/hyq+Oz3antG0VvLQQEtNVUpVQ==
dependencies: dependencies:
"@atproto/common" "*" "@atproto/common" "*"
"@atproto/crypto" "*" "@atproto/crypto" "*"
@@ -124,6 +124,7 @@
pino "^8.6.1" pino "^8.6.1"
pino-http "^8.2.1" pino-http "^8.2.1"
sharp "^0.31.2" sharp "^0.31.2"
typed-emitter "^2.1.0"
uint8arrays "3.0.0" uint8arrays "3.0.0"
"@atproto/plc@*": "@atproto/plc@*":