Add followers and follows list

zio/stable
Paul Frazee 2022-07-26 12:02:34 -05:00
parent 1504d144d9
commit 62eb9f3c93
14 changed files with 645 additions and 26 deletions

View File

@ -15,7 +15,7 @@
"dependencies": { "dependencies": {
"@adxp/auth": "*", "@adxp/auth": "*",
"@adxp/common": "*", "@adxp/common": "*",
"@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878", "@adxp/mock-api": "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e",
"@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",

View File

@ -73,7 +73,11 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
} }
async refresh() { async refresh() {
await this._refresh() await this._fetch(true)
}
async loadMore() {
// TODO
} }
// state transitions // state transitions
@ -105,8 +109,8 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
}) })
} }
private async _fetch() { private async _fetch(isRefreshing = false) {
this._xLoading() this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG await new Promise(r => setTimeout(r, 250)) // DEBUG
try { try {
const res = (await this.rootStore.api.mainPds.view( const res = (await this.rootStore.api.mainPds.view(
@ -120,13 +124,6 @@ export class LikedByViewModel implements bsky.LikedByView.Response {
} }
} }
private async _refresh() {
this._xLoading(true)
// TODO: refetch and update items
await new Promise(r => setTimeout(r, 250)) // DEBUG
this._xIdle()
}
private _replaceAll(res: bsky.LikedByView.Response) { private _replaceAll(res: bsky.LikedByView.Response) {
this.likedBy.length = 0 this.likedBy.length = 0
let counter = 0 let counter = 0

View File

@ -73,7 +73,11 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
} }
async refresh() { async refresh() {
await this._refresh() await this._fetch(true)
}
async loadMore() {
// TODO
} }
// state transitions // state transitions
@ -105,8 +109,8 @@ export class RepostedByViewModel implements bsky.RepostedByView.Response {
}) })
} }
private async _fetch() { private async _fetch(isRefreshing = false) {
this._xLoading() this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG await new Promise(r => setTimeout(r, 250)) // DEBUG
try { try {
const res = (await this.rootStore.api.mainPds.view( const res = (await this.rootStore.api.mainPds.view(

View File

@ -0,0 +1,111 @@
import {makeAutoObservable} from 'mobx'
import {bsky} from '@adxp/mock-api'
import {RootStoreModel} from './root-store'
type Subject = bsky.UserFollowersView.Response['subject']
export type FollowerItem =
bsky.UserFollowersView.Response['followers'][number] & {_reactKey: string}
export class UserFollowersViewModel implements bsky.UserFollowersView.Response {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.UserFollowersView.Params
// data
subject: Subject = {did: '', name: '', displayName: ''}
followers: FollowerItem[] = []
constructor(
public rootStore: RootStoreModel,
params: bsky.UserFollowersView.Params,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
async loadMore() {
// TODO
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:UserFollowersView',
this.params,
)) as bsky.UserFollowersView.Response
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
}
}
private _replaceAll(res: bsky.UserFollowersView.Response) {
this.subject.did = res.subject.did
this.subject.name = res.subject.name
this.subject.displayName = res.subject.displayName
this.followers.length = 0
let counter = 0
for (const item of res.followers) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
private _append(item: FollowerItem) {
this.followers.push(item)
}
}

View File

@ -0,0 +1,112 @@
import {makeAutoObservable} from 'mobx'
import {bsky} from '@adxp/mock-api'
import {RootStoreModel} from './root-store'
type Subject = bsky.UserFollowsView.Response['subject']
export type FollowItem = bsky.UserFollowsView.Response['follows'][number] & {
_reactKey: string
}
export class UserFollowsViewModel implements bsky.UserFollowsView.Response {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
params: bsky.UserFollowsView.Params
// data
subject: Subject = {did: '', name: '', displayName: ''}
follows: FollowItem[] = []
constructor(
public rootStore: RootStoreModel,
params: bsky.UserFollowsView.Params,
) {
makeAutoObservable(
this,
{
rootStore: false,
params: false,
},
{autoBind: true},
)
this.params = params
}
get hasContent() {
return this.subject.did !== ''
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
// public api
// =
async setup() {
await this._fetch()
}
async refresh() {
await this._fetch(true)
}
async loadMore() {
// TODO
}
// state transitions
// =
private _xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
private _xIdle(err: string = '') {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = err
}
// loader functions
// =
private async _fetch(isRefreshing = false) {
this._xLoading(isRefreshing)
await new Promise(r => setTimeout(r, 250)) // DEBUG
try {
const res = (await this.rootStore.api.mainPds.view(
'blueskyweb.xyz:UserFollowsView',
this.params,
)) as bsky.UserFollowsView.Response
this._replaceAll(res)
this._xIdle()
} catch (e: any) {
this._xIdle(`Failed to load feed: ${e.toString()}`)
}
}
private _replaceAll(res: bsky.UserFollowsView.Response) {
this.subject.did = res.subject.did
this.subject.name = res.subject.name
this.subject.displayName = res.subject.displayName
this.follows.length = 0
let counter = 0
for (const item of res.follows) {
this._append({_reactKey: `item-${counter++}`, ...item})
}
}
private _append(item: FollowItem) {
this.follows.push(item)
}
}

View File

@ -0,0 +1,141 @@
import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
FlatList,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import {
UserFollowersViewModel,
FollowerItem,
} from '../../../state/models/user-followers-view'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {AVIS} from '../../lib/assets'
export const ProfileFollowers = observer(function ProfileFollowers({
name,
onNavigateContent,
}: {
name: string
onNavigateContent: OnNavigateContent
}) {
const store = useStores()
const [view, setView] = useState<UserFollowersViewModel | undefined>()
useEffect(() => {
if (view?.params.user === name) {
console.log('User followers doing nothing')
return // no change needed? or trigger refresh?
}
console.log('Fetching user followers', name)
const newView = new UserFollowersViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user followers', err))
}, [name, view?.params.user, store])
// loading
// =
if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View>
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowerItem}) => (
<User item={item} onNavigateContent={onNavigateContent} />
)
return (
<View>
<FlatList
data={view.followers}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const User = ({
item,
onNavigateContent,
}: {
item: FollowerItem
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => {
onNavigateContent('Profile', {
name: item.name,
})
}
return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Image
style={styles.avi}
source={AVIS[item.name] || AVIS['alice.com']}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold]}>{item.displayName}</Text>
<Text style={[s.f14, s.gray]}>@{item.name}</Text>
</View>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
borderTopColor: '#e8e8e8',
backgroundColor: '#fff',
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 30,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
})

View File

@ -0,0 +1,141 @@
import React, {useState, useEffect} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
FlatList,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import {OnNavigateContent} from '../../routes/types'
import {
UserFollowsViewModel,
FollowItem,
} from '../../../state/models/user-follows-view'
import {useStores} from '../../../state'
import {s} from '../../lib/styles'
import {AVIS} from '../../lib/assets'
export const ProfileFollows = observer(function ProfileFollows({
name,
onNavigateContent,
}: {
name: string
onNavigateContent: OnNavigateContent
}) {
const store = useStores()
const [view, setView] = useState<UserFollowsViewModel | undefined>()
useEffect(() => {
if (view?.params.user === name) {
console.log('User follows doing nothing')
return // no change needed? or trigger refresh?
}
console.log('Fetching user follows', name)
const newView = new UserFollowsViewModel(store, {user: name})
setView(newView)
newView
.setup()
.catch(err => console.error('Failed to fetch user follows', err))
}, [name, view?.params.user, store])
// loading
// =
if (
!view ||
(view.isLoading && !view.isRefreshing) ||
view.params.user !== name
) {
return (
<View>
<ActivityIndicator />
</View>
)
}
// error
// =
if (view.hasError) {
return (
<View>
<Text>{view.error}</Text>
</View>
)
}
// loaded
// =
const renderItem = ({item}: {item: FollowItem}) => (
<User item={item} onNavigateContent={onNavigateContent} />
)
return (
<View>
<FlatList
data={view.follows}
keyExtractor={item => item._reactKey}
renderItem={renderItem}
/>
</View>
)
})
const User = ({
item,
onNavigateContent,
}: {
item: FollowItem
onNavigateContent: OnNavigateContent
}) => {
const onPressOuter = () => {
onNavigateContent('Profile', {
name: item.name,
})
}
return (
<TouchableOpacity style={styles.outer} onPress={onPressOuter}>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<Image
style={styles.avi}
source={AVIS[item.name] || AVIS['alice.com']}
/>
</View>
<View style={styles.layoutContent}>
<Text style={[s.f15, s.bold]}>{item.displayName}</Text>
<Text style={[s.f14, s.gray]}>@{item.name}</Text>
</View>
</View>
</TouchableOpacity>
)
}
const styles = StyleSheet.create({
outer: {
borderTopWidth: 1,
borderTopColor: '#e8e8e8',
backgroundColor: '#fff',
},
layout: {
flexDirection: 'row',
},
layoutAvi: {
width: 60,
paddingLeft: 10,
paddingTop: 10,
paddingBottom: 10,
},
avi: {
width: 40,
height: 40,
borderRadius: 30,
resizeMode: 'cover',
},
layoutContent: {
flex: 1,
paddingRight: 10,
paddingTop: 10,
paddingBottom: 10,
},
})

View File

@ -6,6 +6,7 @@ import {
Image, Image,
StyleSheet, StyleSheet,
Text, Text,
TouchableOpacity,
View, View,
} from 'react-native' } from 'react-native'
import {OnNavigateContent} from '../../routes/types' import {OnNavigateContent} from '../../routes/types'
@ -18,8 +19,8 @@ import Toast from '../util/Toast'
export const ProfileHeader = observer(function ProfileHeader({ export const ProfileHeader = observer(function ProfileHeader({
user, user,
}: // onNavigateContent, onNavigateContent,
{ }: {
user: string user: string
onNavigateContent: OnNavigateContent onNavigateContent: OnNavigateContent
}) { }) {
@ -53,6 +54,12 @@ export const ProfileHeader = observer(function ProfileHeader({
err => console.error('Failed to toggle follow', err), err => console.error('Failed to toggle follow', err),
) )
} }
const onPressFollowers = () => {
onNavigateContent('ProfileFollowers', {name: user})
}
const onPressFollows = () => {
onNavigateContent('ProfileFollows', {name: user})
}
// loading // loading
// = // =
@ -91,16 +98,18 @@ export const ProfileHeader = observer(function ProfileHeader({
<Text style={[s.mb5, s.f15, s['lh15-1.3']]}>{view.description}</Text> <Text style={[s.mb5, s.f15, s['lh15-1.3']]}>{view.description}</Text>
)} )}
<View style={s.flexRow}> <View style={s.flexRow}>
<View style={[s.flexRow, s.mr10]}> <TouchableOpacity
style={[s.flexRow, s.mr10]}
onPress={onPressFollowers}>
<Text style={[s.bold, s.mr2]}>{view.followersCount}</Text> <Text style={[s.bold, s.mr2]}>{view.followersCount}</Text>
<Text style={s.gray}> <Text style={s.gray}>
{pluralize(view.followersCount, 'follower')} {pluralize(view.followersCount, 'follower')}
</Text> </Text>
</View> </TouchableOpacity>
<View style={[s.flexRow, s.mr10]}> <TouchableOpacity style={[s.flexRow, s.mr10]} onPress={onPressFollows}>
<Text style={[s.bold, s.mr2]}>{view.followsCount}</Text> <Text style={[s.bold, s.mr2]}>{view.followsCount}</Text>
<Text style={s.gray}>following</Text> <Text style={s.gray}>following</Text>
</View> </TouchableOpacity>
<View style={[s.flexRow, s.mr10]}> <View style={[s.flexRow, s.mr10]}>
<Text style={[s.bold, s.mr2]}>{view.postsCount}</Text> <Text style={[s.bold, s.mr2]}>{view.postsCount}</Text>
<Text style={s.gray}>{pluralize(view.postsCount, 'post')}</Text> <Text style={s.gray}>{pluralize(view.postsCount, 'post')}</Text>

View File

@ -25,6 +25,8 @@ import {PostThread} from '../screens/stacks/PostThread'
import {PostLikedBy} from '../screens/stacks/PostLikedBy' import {PostLikedBy} from '../screens/stacks/PostLikedBy'
import {PostRepostedBy} from '../screens/stacks/PostRepostedBy' import {PostRepostedBy} from '../screens/stacks/PostRepostedBy'
import {Profile} from '../screens/stacks/Profile' import {Profile} from '../screens/stacks/Profile'
import {ProfileFollowers} from '../screens/stacks/ProfileFollowers'
import {ProfileFollows} from '../screens/stacks/ProfileFollows'
const linking: LinkingOptions<RootTabsParamList> = { const linking: LinkingOptions<RootTabsParamList> = {
prefixes: [ prefixes: [
@ -40,6 +42,8 @@ const linking: LinkingOptions<RootTabsParamList> = {
NotificationsTab: 'notifications', NotificationsTab: 'notifications',
MenuTab: 'menu', MenuTab: 'menu',
Profile: 'profile/:name', Profile: 'profile/:name',
ProfileFollowers: 'profile/:name/followers',
ProfileFollows: 'profile/:name/follows',
PostThread: 'profile/:name/post/:recordKey', PostThread: 'profile/:name/post/:recordKey',
PostLikedBy: 'profile/:name/post/:recordKey/liked-by', PostLikedBy: 'profile/:name/post/:recordKey/liked-by',
PostRepostedBy: 'profile/:name/post/:recordKey/reposted-by', PostRepostedBy: 'profile/:name/post/:recordKey/reposted-by',
@ -93,6 +97,11 @@ function HomeStackCom() {
<HomeTabStack.Screen name="Home" component={Home} /> <HomeTabStack.Screen name="Home" component={Home} />
<HomeTabStack.Screen name="Composer" component={Composer} /> <HomeTabStack.Screen name="Composer" component={Composer} />
<HomeTabStack.Screen name="Profile" component={Profile} /> <HomeTabStack.Screen name="Profile" component={Profile} />
<HomeTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<HomeTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
<HomeTabStack.Screen name="PostThread" component={PostThread} /> <HomeTabStack.Screen name="PostThread" component={PostThread} />
<HomeTabStack.Screen name="PostLikedBy" component={PostLikedBy} /> <HomeTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
<HomeTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} /> <HomeTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
@ -109,6 +118,11 @@ function SearchStackCom() {
options={HIDE_HEADER} options={HIDE_HEADER}
/> />
<SearchTabStack.Screen name="Profile" component={Profile} /> <SearchTabStack.Screen name="Profile" component={Profile} />
<SearchTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<SearchTabStack.Screen name="ProfileFollows" component={ProfileFollows} />
<SearchTabStack.Screen name="PostThread" component={PostThread} /> <SearchTabStack.Screen name="PostThread" component={PostThread} />
<SearchTabStack.Screen name="PostLikedBy" component={PostLikedBy} /> <SearchTabStack.Screen name="PostLikedBy" component={PostLikedBy} />
<SearchTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} /> <SearchTabStack.Screen name="PostRepostedBy" component={PostRepostedBy} />
@ -124,6 +138,14 @@ function NotificationsStackCom() {
component={Notifications} component={Notifications}
/> />
<NotificationsTabStack.Screen name="Profile" component={Profile} /> <NotificationsTabStack.Screen name="Profile" component={Profile} />
<NotificationsTabStack.Screen
name="ProfileFollowers"
component={ProfileFollowers}
/>
<NotificationsTabStack.Screen
name="ProfileFollows"
component={ProfileFollows}
/>
<NotificationsTabStack.Screen name="PostThread" component={PostThread} /> <NotificationsTabStack.Screen name="PostThread" component={PostThread} />
<NotificationsTabStack.Screen <NotificationsTabStack.Screen
name="PostLikedBy" name="PostLikedBy"

View File

@ -6,6 +6,8 @@ export type RootTabsParamList = {
NotificationsTab: undefined NotificationsTab: undefined
MenuTab: undefined MenuTab: undefined
Profile: {name: string} Profile: {name: string}
ProfileFollowers: {name: string}
ProfileFollows: {name: string}
PostThread: {name: string; recordKey: string} PostThread: {name: string; recordKey: string}
PostLikedBy: {name: string; recordKey: string} PostLikedBy: {name: string; recordKey: string}
PostRepostedBy: {name: string; recordKey: string} PostRepostedBy: {name: string; recordKey: string}

View File

@ -0,0 +1,39 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {ProfileFollowers as ProfileFollowersComponent} from '../../com/profile/ProfileFollowers'
export const ProfileFollowers = ({
navigation,
route,
}: RootTabsScreenProps<'ProfileFollowers'>) => {
const {name} = route.params
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Followers',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<ProfileFollowersComponent
name={name}
onNavigateContent={onNavigateContent}
/>
</Shell>
)
}

View File

@ -0,0 +1,39 @@
import React, {useLayoutEffect} from 'react'
import {TouchableOpacity} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Shell} from '../../shell'
import type {RootTabsScreenProps} from '../../routes/types'
import {ProfileFollows as ProfileFollowsComponent} from '../../com/profile/ProfileFollows'
export const ProfileFollows = ({
navigation,
route,
}: RootTabsScreenProps<'ProfileFollows'>) => {
const {name} = route.params
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
headerTitle: 'Following',
headerLeft: () => (
<TouchableOpacity onPress={() => navigation.goBack()}>
<FontAwesomeIcon icon="arrow-left" />
</TouchableOpacity>
),
})
}, [navigation])
const onNavigateContent = (screen: string, props: Record<string, string>) => {
// @ts-ignore it's up to the callers to supply correct params -prf
navigation.push(screen, props)
}
return (
<Shell>
<ProfileFollowsComponent
name={name}
onNavigateContent={onNavigateContent}
/>
</Shell>
)
}

View File

@ -1,8 +1,5 @@
Paul's todo list Paul's todo list
- Profile view
- Followers list
- Follows list
- Composer - Composer
- Check on navigation stack during a bunch of replies - Check on navigation stack during a bunch of replies
- Search view - Search view
@ -11,4 +8,9 @@ Paul's todo list
- * - *
- Linking - Linking
- Web linking - Web linking
- App linking - App linking
- Pagination
- Liked by
- Reposted by
- Followers list
- Follows list

View File

@ -55,9 +55,9 @@
ucans "0.9.0-alpha3" ucans "0.9.0-alpha3"
uint8arrays "^3.0.0" uint8arrays "^3.0.0"
"@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878": "@adxp/mock-api@git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e":
version "0.0.1" version "0.0.1"
resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#0159e865560c12fb7004862c7d9d48420ed93878" resolved "git+ssh://git@github.com:bluesky-social/adx-mock-api.git#6d700ac04affe31030120975c128f1849c8ae98e"
dependencies: dependencies:
ajv "^8.11.0" ajv "^8.11.0"
ajv-formats "^2.1.1" ajv-formats "^2.1.1"