From 75fd653be3a391d0edc11a9b35ed636c7cbe3b11 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 18 Apr 2023 18:29:54 -0500 Subject: [PATCH] Rework search suggestions for performance (#492) --- package.json | 2 +- src/state/models/discovery/foafs.ts | 16 +- src/view/com/discover/SuggestedFollows.tsx | 68 ----- src/view/com/search/Suggestions.tsx | 290 +++++++++++++++++---- src/view/screens/Search.tsx | 197 +------------- src/view/screens/Search.web.tsx | 59 +---- src/view/screens/SearchMobile.tsx | 87 +++---- yarn.lock | 41 +-- 8 files changed, 312 insertions(+), 448 deletions(-) delete mode 100644 src/view/com/discover/SuggestedFollows.tsx diff --git a/package.json b/package.json index 1621b4ab..120948f0 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "await-lock": "^2.2.2", "base64-js": "^1.5.1", "email-validator": "^2.0.4", - "expo": "~48.0.10", + "expo": "~48.0.11", "expo-build-properties": "~0.5.1", "expo-camera": "~13.2.1", "expo-dev-client": "~2.1.1", diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 8dac2ec2..f6e3157b 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -57,15 +57,19 @@ export class FoafsModel { } // grab 10 of the users followed by the user - this.sources = sampleSize( - Object.keys(this.rootStore.me.follows.followDidToRecordMap), - 10, - ) + runInAction(() => { + this.sources = sampleSize( + Object.keys(this.rootStore.me.follows.followDidToRecordMap), + 10, + ) + }) if (this.sources.length === 0) { return } - this.foafs.clear() - this.popular.length = 0 + runInAction(() => { + this.foafs.clear() + this.popular.length = 0 + }) // fetch their profiles const profiles = await this.rootStore.agent.getProfiles({ diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx deleted file mode 100644 index ae5605c5..00000000 --- a/src/view/com/discover/SuggestedFollows.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' -import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {Text} from '../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' - -export const SuggestedFollows = ({ - title, - suggestions, -}: { - title: string - suggestions: ( - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileView - | RefWithInfoAndFollowers - )[] -}) => { - const pal = usePalette('default') - return ( - - - {title} - - {suggestions.map(item => ( - - - - ))} - - ) -} - -const styles = StyleSheet.create({ - container: { - borderBottomWidth: 1, - }, - - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingBottom: 8, - }, - - card: { - borderTopWidth: 1, - }, -}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx index e9999e1d..c1355bfb 100644 --- a/src/view/com/search/Suggestions.tsx +++ b/src/view/com/search/Suggestions.tsx @@ -1,65 +1,255 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' +import React, {forwardRef, ForwardedRef} from 'react' +import {RefreshControl, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' +import {AppBskyActorDefs} from '@atproto/api' +import {CenteredView, FlatList} from '../util/Views' import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {SuggestedFollows} from 'view/com/discover/SuggestedFollows' +import { + SuggestedActorsModel, + SuggestedActor, +} from 'state/models/discovery/suggested-actors' +import {Text} from '../util/text/Text' +import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {sanitizeDisplayName} from 'lib/strings/display-names' +import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' +import {usePalette} from 'lib/hooks/usePalette' + +interface Heading { + _reactKey: string + type: 'heading' + title: string +} +interface RefWrapper { + _reactKey: string + type: 'ref' + ref: RefWithInfoAndFollowers +} +interface SuggestWrapper { + _reactKey: string + type: 'suggested' + suggested: SuggestedActor +} +interface ProfileView { + _reactKey: string + type: 'profile-view' + view: AppBskyActorDefs.ProfileViewBasic +} +type Item = Heading | RefWrapper | SuggestWrapper | ProfileView export const Suggestions = observer( - ({ - foafs, - suggestedActors, - }: { - foafs: FoafsModel - suggestedActors: SuggestedActorsModel - }) => { - if (foafs.isLoading || suggestedActors.isLoading) { - return - } - return ( - <> - {foafs.popular.length > 0 && ( - - - - )} - {suggestedActors.hasContent && ( - - - - )} - {foafs.sources.map((source, i) => { + forwardRef( + ( + { + foafs, + suggestedActors, + }: { + foafs: FoafsModel + suggestedActors: SuggestedActorsModel + }, + flatListRef: ForwardedRef, + ) => { + const pal = usePalette('default') + const [refreshing, setRefreshing] = React.useState(false) + const data = React.useMemo(() => { + let items: Item[] = [] + + if (foafs.popular.length > 0) { + items = items + .concat([ + { + _reactKey: '__popular_heading__', + type: 'heading', + title: 'In your network', + }, + ]) + .concat( + foafs.popular.map(ref => ({ + _reactKey: `popular-${ref.did}`, + type: 'ref', + ref, + })), + ) + } + if (suggestedActors.hasContent) { + items = items + .concat([ + { + _reactKey: '__suggested_heading__', + type: 'heading', + title: 'Suggested follows', + }, + ]) + .concat( + suggestedActors.suggestions.map(suggested => ({ + _reactKey: `suggested-${suggested.did}`, + type: 'suggested', + suggested, + })), + ) + } + for (const source of foafs.sources) { const item = foafs.foafs.get(source) if (!item || item.follows.length === 0) { - return + return } - return ( - - - - ) - })} - - ) - }, + )}`, + }, + ]) + .concat( + item.follows.slice(0, 10).map(view => ({ + _reactKey: `${item.did}-${view.did}`, + type: 'profile-view', + view, + })), + ) + } + + return items + }, [ + foafs.popular, + suggestedActors.hasContent, + suggestedActors.suggestions, + foafs.sources, + foafs.foafs, + ]) + + const onRefresh = React.useCallback(async () => { + setRefreshing(true) + try { + await foafs.fetch() + } finally { + setRefreshing(false) + } + }, [foafs, setRefreshing]) + + const renderItem = React.useCallback( + ({item}: {item: Item}) => { + if (item.type === 'heading') { + return ( + + {item.title} + + ) + } + if (item.type === 'ref') { + return ( + + + + ) + } + if (item.type === 'profile-view') { + return ( + + + + ) + } + if (item.type === 'suggested') { + return ( + + + + ) + } + return null + }, + [pal], + ) + + if (foafs.isLoading || suggestedActors.isLoading) { + return ( + + + + ) + } + return ( + item._reactKey} + refreshControl={ + + } + renderItem={renderItem} + initialNumToRender={15} + /> + ) + }, + ), ) const styles = StyleSheet.create({ - suggestions: { - marginTop: 10, - marginBottom: 20, + heading: { + fontWeight: 'bold', + paddingHorizontal: 12, + paddingBottom: 8, + paddingTop: 16, + }, + + card: { + borderTopWidth: 1, }, }) diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index ed9effd0..bf9857df 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -1,196 +1 @@ -import React from 'react' -import { - Keyboard, - RefreshControl, - StyleSheet, - TouchableWithoutFeedback, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from 'view/com/util/Views' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {s} from 'lib/styles' -import {ProfileCard} from 'view/com/profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' - -type Props = NativeStackScreenProps -export const SearchScreen = withAuthRequired( - observer(({}: Props) => { - const pal = usePalette('default') - const store = useStores() - const scrollElRef = React.useRef(null) - const onMainScroll = useOnMainScroll(store) - const [isInputFocused, setIsInputFocused] = React.useState(false) - const [query, setQuery] = React.useState('') - const autocompleteView = React.useMemo( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo( - () => new SuggestedActorsModel(store), - [store], - ) - const [searchUIModel, setSearchUIModel] = React.useState< - SearchUIModel | undefined - >() - const [refreshing, setRefreshing] = React.useState(false) - - const onSoftReset = () => { - scrollElRef.current?.scrollTo({x: 0, y: 0}) - } - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - store.shell.setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [store, autocompleteView, foafs, suggestedActors]), - ) - - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - }, - [setQuery, autocompleteView], - ) - - const onPressClearQuery = React.useCallback(() => { - setQuery('') - }, [setQuery]) - - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - autocompleteView.setActive(false) - setSearchUIModel(undefined) - store.shell.setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, store]) - - const onSubmitQuery = React.useCallback(() => { - const model = new SearchUIModel(store) - model.fetch(query) - setSearchUIModel(model) - store.shell.setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store]) - - const onRefresh = React.useCallback(async () => { - setRefreshing(true) - try { - await foafs.fetch() - } finally { - setRefreshing(false) - } - }, [foafs, setRefreshing]) - - return ( - - - - {searchUIModel ? ( - - ) : ( - - }> - {query && autocompleteView.searchRes.length ? ( - <> - {autocompleteView.searchRes.map(item => ( - - ))} - - ) : query && !autocompleteView.searchRes.length ? ( - - - No results found for {autocompleteView.prefix} - - - ) : isInputFocused ? ( - - - Search for users on the network - - - ) : ( - - )} - - - )} - - - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) +export * from './SearchMobile' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx index 92df1d92..62f3fb90 100644 --- a/src/view/screens/Search.web.tsx +++ b/src/view/screens/Search.web.tsx @@ -1,10 +1,8 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from 'view/com/util/Views' import {Suggestions} from 'view/com/search/Suggestions' import {SearchResults} from 'view/com/search/SearchResults' import {observer} from 'mobx-react-lite' @@ -13,15 +11,12 @@ import { SearchTabNavigatorParams, } from 'lib/routes/types' import {useStores} from 'state/index' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' import * as Mobile from './SearchMobile' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' type Props = NativeStackScreenProps export const SearchScreen = withAuthRequired( observer(({navigation, route}: Props) => { - const pal = usePalette('default') const store = useStores() const params = route.params || {} const foafs = React.useMemo( @@ -59,58 +54,6 @@ export const SearchScreen = withAuthRequired( return } - return ( - - - - - ) + return }), ) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 12, - paddingTop: 4, - marginBottom: 14, - }, - headerMenuBtn: { - width: 40, - height: 30, - marginLeft: 6, - }, - headerSearchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 30, - paddingHorizontal: 12, - paddingVertical: 8, - }, - headerSearchIcon: { - marginRight: 6, - alignSelf: 'center', - }, - headerSearchInput: { - flex: 1, - fontSize: 17, - }, - headerCancelBtn: { - width: 60, - paddingLeft: 10, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx index e1fb3ec0..679fb07c 100644 --- a/src/view/screens/SearchMobile.tsx +++ b/src/view/screens/SearchMobile.tsx @@ -1,14 +1,13 @@ import React from 'react' import { Keyboard, - RefreshControl, StyleSheet, TouchableWithoutFeedback, View, } from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ScrollView} from 'view/com/util/Views' +import {FlatList, ScrollView} from 'view/com/util/Views' import { NativeStackScreenProps, SearchTabNavigatorParams, @@ -33,7 +32,8 @@ export const SearchScreen = withAuthRequired( observer(({}: Props) => { const pal = usePalette('default') const store = useStores() - const scrollElRef = React.useRef(null) + const scrollViewRef = React.useRef(null) + const flatListRef = React.useRef(null) const onMainScroll = useOnMainScroll(store) const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') @@ -52,31 +52,6 @@ export const SearchScreen = withAuthRequired( const [searchUIModel, setSearchUIModel] = React.useState< SearchUIModel | undefined >() - const [refreshing, setRefreshing] = React.useState(false) - - const onSoftReset = () => { - scrollElRef.current?.scrollTo({x: 0, y: 0}) - } - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - store.shell.setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [store, autocompleteView, foafs, suggestedActors]), - ) const onChangeQuery = React.useCallback( (text: string) => { @@ -109,14 +84,31 @@ export const SearchScreen = withAuthRequired( store.shell.setIsDrawerSwipeDisabled(true) }, [query, setSearchUIModel, store]) - const onRefresh = React.useCallback(async () => { - setRefreshing(true) - try { - await foafs.fetch() - } finally { - setRefreshing(false) - } - }, [foafs, setRefreshing]) + const onSoftReset = React.useCallback(() => { + scrollViewRef.current?.scrollTo({x: 0, y: 0}) + flatListRef.current?.scrollToOffset({offset: 0}) + onPressCancelSearch() + }, [scrollViewRef, flatListRef, onPressCancelSearch]) + + useFocusEffect( + React.useCallback(() => { + const softResetSub = store.onScreenSoftReset(onSoftReset) + const cleanup = () => { + softResetSub.remove() + } + + store.shell.setMinimalShellMode(false) + autocompleteView.setup() + if (!foafs.hasData) { + foafs.fetch() + } + if (!suggestedActors.hasLoaded) { + suggestedActors.loadMore(true) + } + + return cleanup + }, [store, autocompleteView, foafs, suggestedActors, onSoftReset]), + ) return ( @@ -132,21 +124,19 @@ export const SearchScreen = withAuthRequired( /> {searchUIModel ? ( + ) : !isInputFocused && !query ? ( + ) : ( - }> + scrollEventThrottle={100}> {query && autocompleteView.searchRes.length ? ( <> {autocompleteView.searchRes.map(item => ( @@ -155,6 +145,7 @@ export const SearchScreen = withAuthRequired( testID={`searchAutoCompleteResult-${item.handle}`} handle={item.handle} displayName={item.displayName} + labels={item.labels} avatar={item.avatar} /> ))} @@ -171,9 +162,7 @@ export const SearchScreen = withAuthRequired( Search for users on the network - ) : ( - - )} + ) : null} )} diff --git a/yarn.lock b/yarn.lock index 5a014ff1..8685a0d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1535,16 +1535,16 @@ mv "~2" safe-json-stringify "~1" -"@expo/cli@0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.6.2.tgz#1090c9d23f49d9603c4c85fa85b878b2848da322" - integrity sha512-uhmrXNemXTbCTKP/ycyJHOU/KLGdFwVCrWNBzz1VkwnmL8yJV5F3C18a83ybFFnUNfkGHeH5LtID7CSNbbTWKg== +"@expo/cli@0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.7.0.tgz#2a16873ced05c1f3b7f3990d7b410e9853600f45" + integrity sha512-9gjr3pRgwWzUDW/P7B4tA0QevKb+hCrvTmVc3Ce5w7CjdM3zNoBcro8vwviRHqkiB1IifG7zQh0PPStSbK+FRQ== dependencies: "@babel/runtime" "^7.20.0" "@expo/code-signing-certificates" "0.0.5" "@expo/config" "~8.0.0" "@expo/config-plugins" "~6.0.0" - "@expo/dev-server" "0.2.3" + "@expo/dev-server" "0.3.0" "@expo/devcert" "^1.0.0" "@expo/json-file" "^8.2.37" "@expo/metro-config" "~0.7.0" @@ -1600,6 +1600,7 @@ text-table "^0.2.0" url-join "4.0.0" wrap-ansi "^7.0.0" + ws "^8.12.1" "@expo/code-signing-certificates@0.0.5": version "0.0.5" @@ -1709,10 +1710,10 @@ xcode "^3.0.0" xml-js "^1.6.11" -"@expo/dev-server@0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@expo/dev-server/-/dev-server-0.2.3.tgz#736317cc1340b28dc49da8a45b85040306048e24" - integrity sha512-9+6QGRdymj3dmTp1vUpROvWJ+Ezz6Qp9xHafAcaRHzw322pUCOiRKxTYqDqYYZ/72shrHPGQ2CiIXTnV1vM2tA== +"@expo/dev-server@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@expo/dev-server/-/dev-server-0.3.0.tgz#c575c88b0ec28f127f328a80ea6a3a4c6f785800" + integrity sha512-2A6/8uZADSKAtzyR6YqhCBUFxb5DFmjxmFn0EHMqnPnsh13ZSiKEjrZPrRkM6Li2EHLYqHK2rmweJ7O/7q9pPQ== dependencies: "@expo/bunyan" "4.0.0" "@expo/metro-config" "~0.7.0" @@ -8390,10 +8391,10 @@ expo-media-library@~15.2.3: resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-15.2.3.tgz#188f3c77f58b354f0ea6250f6756ac1e1a226291" integrity sha512-Oz8b8Xsvfj7YcutUBtI84NUIqSnt7iCM5HZ5DyKoWKKiDK/+aUuj3RXNQELG8jUw6pQPgEwgbZ1+J8SdH/y9jw== -expo-modules-autolinking@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-1.1.2.tgz#a81c65c63bd281922410c6d8c3ad6255b6305246" - integrity sha512-oOlkAccVnHwwR5ccvF/F/x4Omj9HWzSimMUlIVz0SVGdNBEqTPyn0L/d4uIufhyQbEWvrarqL8o5Yz11wEI0SQ== +expo-modules-autolinking@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-1.2.0.tgz#3ead115510a43fe196fc0498586b6133bd573209" + integrity sha512-QOPh/iXykNDCAzUual1imSrn2aDakzCGUp2QmxVREr0llajXygroUWlT9sQXh1zKzbNp+a+i/xK375ZeBFiNJA== dependencies: chalk "^4.1.0" commander "^7.2.0" @@ -8467,13 +8468,13 @@ expo-updates@~0.16.4: fbemitter "^3.0.0" resolve-from "^5.0.0" -expo@~48.0.10: - version "48.0.10" - resolved "https://registry.yarnpkg.com/expo/-/expo-48.0.10.tgz#c1218f6a0ca9ca8209d6f833dc6911743870dfaf" - integrity sha512-8YXG6um3ld36nu/ONEC0NNkMatdj4k/HwR7OUd3dKUt3PJSkZHsCeLXIu8za7WSWpgPAU/xAj35noPFEFnjO1w== +expo@~48.0.11: + version "48.0.11" + resolved "https://registry.yarnpkg.com/expo/-/expo-48.0.11.tgz#afd43c7a5ddce3d02a3f27263c95f8d01e1fb84d" + integrity sha512-KX1RCHhdhdT4DjCeRqYJpZXhdCTuqxHHdNIRoFkmCgkUARYlZbB+Y1U8/KMz8fBAlFoEq99cF/KyRr87VAxRCw== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.6.2" + "@expo/cli" "0.7.0" "@expo/config" "8.0.2" "@expo/config-plugins" "6.0.1" "@expo/vector-icons" "^13.0.0" @@ -8485,7 +8486,7 @@ expo@~48.0.10: expo-file-system "~15.2.2" expo-font "~11.1.1" expo-keep-awake "~12.0.1" - expo-modules-autolinking "1.1.2" + expo-modules-autolinking "1.2.0" expo-modules-core "1.2.6" fbemitter "^3.0.0" getenv "^1.0.0" @@ -17884,7 +17885,7 @@ ws@^7, ws@^7.0.0, ws@^7.4.6, ws@^7.5.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== -ws@^8.11.0, ws@^8.13.0: +ws@^8.11.0, ws@^8.12.1, ws@^8.13.0: version "8.13.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==