diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 19252f7654..3ef19fc462 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -235,6 +235,7 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
+ e.GET("/topic/:topic", server.WebGeneric)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
diff --git a/package.json b/package.json
index f5f4601d6c..ff2223b489 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.13.20",
+ "@atproto/api": "^0.13.21",
"@bitdrift/react-native": "0.4.0",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 39ab7ca92c..780295ddce 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -57,6 +57,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as TrendingConfigProvider} from '#/state/trending-config'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
@@ -143,12 +144,14 @@ function InnerApp() {
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 8d13a826e7..8a2e13600f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -47,6 +47,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as TrendingConfigProvider} from '#/state/trending-config'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast'
@@ -127,8 +128,10 @@ function InnerApp() {
-
-
+
+
+
+
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 7443128d2c..18705c5ffb 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -100,6 +100,7 @@ import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings'
import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings'
import {SettingsScreen} from './screens/Settings/Settings'
import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences'
+import TopicScreen from './screens/Topic'
const navigationRef = createNavigationContainerRef()
@@ -376,6 +377,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
+ TopicScreen}
+ options={{title: title(msg`Topic`)}}
+ />
MessagesConversationScreen}
diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx
index 3dff404d74..9ad6ed7dc6 100644
--- a/src/components/GradientFill.tsx
+++ b/src/components/GradientFill.tsx
@@ -1,11 +1,13 @@
import {LinearGradient} from 'expo-linear-gradient'
-import {atoms as a, tokens} from '#/alf'
+import {atoms as a, tokens, ViewStyleProp} from '#/alf'
export function GradientFill({
gradient,
-}: {
+ style,
+}: ViewStyleProp & {
gradient:
+ | typeof tokens.gradients.primary
| typeof tokens.gradients.sky
| typeof tokens.gradients.midnight
| typeof tokens.gradients.sunrise
@@ -26,7 +28,7 @@ export function GradientFill({
}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
- style={[a.absolute, a.inset_0]}
+ style={[a.absolute, a.inset_0, style]}
/>
)
}
diff --git a/src/components/TrendingTopics.tsx b/src/components/TrendingTopics.tsx
new file mode 100644
index 0000000000..6881f24bd5
--- /dev/null
+++ b/src/components/TrendingTopics.tsx
@@ -0,0 +1,223 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AtUri} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+// import {makeProfileLink} from '#/lib/routes/links'
+// import {feedUriToHref} from '#/lib/strings/url-helpers'
+// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote'
+// import {UserAvatar} from '#/view/com/util/UserAvatar'
+import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function TrendingTopic({
+ topic: raw,
+ size,
+ style,
+}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) {
+ const t = useTheme()
+ const topic = useTopic(raw)
+
+ const isSmall = size === 'small'
+ // const hasAvi = topic.type === 'feed' || topic.type === 'profile'
+ // const aviSize = isSmall ? 16 : 20
+ // const iconSize = isSmall ? 16 : 20
+
+ return (
+
+ {/*
+
+ {topic.type === 'tag' ? (
+
+ ) : topic.type === 'topic' ? (
+
+ ) : topic.type === 'feed' ? (
+
+ ) : (
+
+ )}
+
+ */}
+
+
+ {topic.displayName}
+
+
+ )
+}
+
+export function TrendingTopicSkeleton({
+ size = 'large',
+ index = 0,
+}: {
+ size?: 'large' | 'small'
+ index?: number
+}) {
+ const t = useTheme()
+ const isSmall = size === 'small'
+ return (
+
+ )
+}
+
+export function TrendingTopicLink({
+ topic: raw,
+ children,
+ ...rest
+}: {
+ topic: TrendingTopic
+} & Omit) {
+ const topic = useTopic(raw)
+
+ return (
+
+ {children}
+
+ )
+}
+
+type ParsedTrendingTopic =
+ | {
+ type: 'topic' | 'tag' | 'unknown'
+ label: string
+ displayName: string
+ url: string
+ uri: undefined
+ }
+ | {
+ type: 'profile' | 'feed'
+ label: string
+ displayName: string
+ url: string
+ uri: AtUri
+ }
+
+export function useTopic(raw: TrendingTopic): ParsedTrendingTopic {
+ const {_} = useLingui()
+ return React.useMemo(() => {
+ const {topic: displayName, link} = raw
+
+ if (link.startsWith('/search')) {
+ return {
+ type: 'topic',
+ label: _(msg`Browse posts about ${displayName}`),
+ displayName,
+ uri: undefined,
+ url: link,
+ }
+ } else if (link.startsWith('/hashtag')) {
+ return {
+ type: 'tag',
+ label: _(msg`Browse posts tagged with ${displayName}`),
+ displayName,
+ // displayName: displayName.replace(/^#/, ''),
+ uri: undefined,
+ url: link,
+ }
+ }
+
+ /*
+ if (!link.startsWith('at://')) {
+ // above logic
+ } else {
+ const urip = new AtUri(link)
+ switch (urip.collection) {
+ case 'app.bsky.actor.profile': {
+ return {
+ type: 'profile',
+ label: _(msg`View ${displayName}'s profile`),
+ displayName,
+ uri: urip,
+ url: makeProfileLink({did: urip.host, handle: urip.host}),
+ }
+ }
+ case 'app.bsky.feed.generator': {
+ return {
+ type: 'feed',
+ label: _(msg`Browse the ${displayName} feed`),
+ displayName,
+ uri: urip,
+ url: feedUriToHref(link),
+ }
+ }
+ }
+ }
+ */
+
+ return {
+ type: 'unknown',
+ label: _(msg`Browse topic ${displayName}`),
+ displayName,
+ uri: undefined,
+ url: link,
+ }
+ }, [_, raw])
+}
diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx
new file mode 100644
index 0000000000..3944d92f07
--- /dev/null
+++ b/src/components/interstitials/Trending.tsx
@@ -0,0 +1,111 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+ useTrendingSettings,
+ useTrendingSettingsApi,
+} from '#/state/preferences/trending'
+import {
+ DEFAULT_LIMIT as TRENDING_TOPICS_COUNT,
+ useTrendingTopics,
+} from '#/state/queries/trending/useTrendingTopics'
+import {useTrendingConfig} from '#/state/trending-config'
+import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {GradientFill} from '#/components/GradientFill'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import * as Prompt from '#/components/Prompt'
+import {
+ TrendingTopic,
+ TrendingTopicLink,
+ TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+export function TrendingInterstitial() {
+ const {enabled} = useTrendingConfig()
+ const {trendingDisabled} = useTrendingSettings()
+ return enabled && !trendingDisabled ? : null
+}
+
+export function Inner() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const gutters = useGutters(['wide', 'base'])
+ const trendingPrompt = Prompt.usePromptControl()
+ const {setTrendingDisabled} = useTrendingSettingsApi()
+ const {data: trending, error, isLoading} = useTrendingTopics()
+ const noTopics = !isLoading && !error && !trending?.topics?.length
+
+ return error || noTopics ? null : (
+
+
+
+
+
+ Trending
+
+
+
+
+ BETA
+
+
+
+
+
+
+
+
+ {isLoading ? (
+ Array(TRENDING_TOPICS_COUNT)
+ .fill(0)
+ .map((_n, i) => )
+ ) : !trending?.topics ? null : (
+ <>
+ {trending.topics.map(topic => (
+
+ {({hovered}) => (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ setTrendingDisabled(true)}
+ />
+
+ )
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 238e4be4c3..d720886e9f 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -47,6 +47,7 @@ export type CommonNavigatorParams = {
AppIconSettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
+ Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
NotificationSettings: undefined
@@ -92,6 +93,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
+ Topic: {topic: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
@@ -105,6 +107,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
+ Topic: {topic: string}
MessagesTab: undefined
Messages: {animation?: 'push' | 'pop'}
Start: {name: string; rkey: string}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index a6c2492548..455a703458 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -4,3 +4,4 @@ export type Gate =
| 'debug_subscriptions'
| 'new_postonboarding'
| 'remove_show_latest_button'
+ | 'trending_topics_beta'
diff --git a/src/routes.ts b/src/routes.ts
index 188665d849..7cd7c0880d 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -53,6 +53,7 @@ export const router = new Router({
CopyrightPolicy: '/support/copyright',
// hashtags
Hashtag: '/hashtag/:tag',
+ Topic: '/topic/:topic',
// DMs
Messages: '/messages',
MessagesSettings: '/messages/settings',
diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx
new file mode 100644
index 0000000000..e253cfb5ab
--- /dev/null
+++ b/src/screens/Search/components/ExploreRecommendations.tsx
@@ -0,0 +1,95 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from '#/platform/detection'
+import {useTrendingSettings} from '#/state/preferences/trending'
+import {
+ DEFAULT_LIMIT as RECOMMENDATIONS_COUNT,
+ useTrendingTopics,
+} from '#/state/queries/trending/useTrendingTopics'
+import {useTrendingConfig} from '#/state/trending-config'
+import {atoms as a, useGutters, useTheme} from '#/alf'
+import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag'
+import {
+ TrendingTopic,
+ TrendingTopicLink,
+ TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+export function ExploreRecommendations() {
+ const {enabled} = useTrendingConfig()
+ const {trendingDisabled} = useTrendingSettings()
+ return enabled && !trendingDisabled ? : null
+}
+
+function Inner() {
+ const t = useTheme()
+ const gutters = useGutters([0, 'compact'])
+ const {data: trending, error, isLoading} = useTrendingTopics()
+ const noRecs = !isLoading && !error && !trending?.suggested?.length
+
+ return error || noRecs ? null : (
+ <>
+
+
+
+
+
+ Recommended
+
+
+
+ Feeds we think you might like.
+
+
+
+
+
+
+ {isLoading ? (
+ Array(RECOMMENDATIONS_COUNT)
+ .fill(0)
+ .map((_, i) => )
+ ) : !trending?.suggested ? null : (
+ <>
+ {trending.suggested.map(topic => (
+
+ {({hovered}) => (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ >
+ )
+}
diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx
new file mode 100644
index 0000000000..be347dcd4d
--- /dev/null
+++ b/src/screens/Search/components/ExploreTrendingTopics.tsx
@@ -0,0 +1,102 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from '#/platform/detection'
+import {useTrendingSettings} from '#/state/preferences/trending'
+import {
+ DEFAULT_LIMIT as TRENDING_TOPICS_COUNT,
+ useTrendingTopics,
+} from '#/state/queries/trending/useTrendingTopics'
+import {useTrendingConfig} from '#/state/trending-config'
+import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
+import {GradientFill} from '#/components/GradientFill'
+import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
+import {
+ TrendingTopic,
+ TrendingTopicLink,
+ TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+export function ExploreTrendingTopics() {
+ const {enabled} = useTrendingConfig()
+ const {trendingDisabled} = useTrendingSettings()
+ return enabled && !trendingDisabled ? : null
+}
+
+function Inner() {
+ const t = useTheme()
+ const gutters = useGutters([0, 'compact'])
+ const {data: trending, error, isLoading} = useTrendingTopics()
+ const noTopics = !isLoading && !error && !trending?.topics?.length
+
+ return error || noTopics ? null : (
+ <>
+
+
+
+
+
+ Trending
+
+
+
+
+ BETA
+
+
+
+
+ What people are posting about.
+
+
+
+
+
+
+ {isLoading ? (
+ Array(TRENDING_TOPICS_COUNT)
+ .fill(0)
+ .map((_, i) => )
+ ) : !trending?.topics ? null : (
+ <>
+ {trending.topics.map(topic => (
+
+ {({hovered}) => (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ >
+ )
+}
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index 17f8fa5067..bdbe1d191b 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -9,6 +9,11 @@ import {
useInAppBrowser,
useSetInAppBrowser,
} from '#/state/preferences/in-app-browser'
+import {
+ useTrendingSettings,
+ useTrendingSettingsApi,
+} from '#/state/preferences/trending'
+import {useTrendingConfig} from '#/state/trending-config'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import * as Toggle from '#/components/forms/Toggle'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
@@ -16,6 +21,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons
import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home'
import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window'
import * as Layout from '#/components/Layout'
@@ -29,6 +35,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
const setAutoplayDisabledPref = useSetAutoplayDisabled()
const inAppBrowserPref = useInAppBrowser()
const setUseInAppBrowser = useSetInAppBrowser()
+ const {enabled: trendingEnabled} = useTrendingConfig()
+ const {trendingDisabled} = useTrendingSettings()
+ const {setTrendingDisabled} = useTrendingSettingsApi()
return (
@@ -104,6 +113,24 @@ export function ContentAndMediaSettingsScreen({}: Props) {
+ {trendingEnabled && (
+ <>
+
+ setTrendingDisabled(!value)}>
+
+
+
+ Enable trending topics
+
+
+
+
+ >
+ )}
diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx
new file mode 100644
index 0000000000..6cd69f05f2
--- /dev/null
+++ b/src/screens/Topic.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {CommonNavigatorParams} from '#/lib/routes/types'
+import {shareUrl} from '#/lib/sharing'
+import {cleanError} from '#/lib/strings/errors'
+import {enforceLen} from '#/lib/strings/helpers'
+import {useSearchPostsQuery} from '#/state/queries/search-posts'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Pager} from '#/view/com/pager/Pager'
+import {TabBar} from '#/view/com/pager/TabBar'
+import {Post} from '#/view/com/post/Post'
+import {List} from '#/view/com/util/List'
+import {atoms as a, web} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import * as Layout from '#/components/Layout'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+
+const renderItem = ({item}: ListRenderItemInfo) => {
+ return
+}
+
+const keyExtractor = (item: PostView, index: number) => {
+ return `${item.uri}-${index}`
+}
+
+export default function TopicScreen({
+ route,
+}: NativeStackScreenProps) {
+ const {topic} = route.params
+ const {_} = useLingui()
+
+ const headerTitle = React.useMemo(() => {
+ return enforceLen(decodeURIComponent(topic), 24, true, 'middle')
+ }, [topic])
+
+ const onShare = React.useCallback(() => {
+ const url = new URL('https://bsky.app')
+ url.pathname = `/topic/${topic}`
+ shareUrl(url.toString())
+ }, [topic])
+
+ const [activeTab, setActiveTab] = React.useState(0)
+ const setMinimalShellMode = useSetMinimalShellMode()
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setMinimalShellMode(false)
+ }, [setMinimalShellMode]),
+ )
+
+ const onPageSelected = React.useCallback(
+ (index: number) => {
+ setMinimalShellMode(false)
+ setActiveTab(index)
+ },
+ [setMinimalShellMode],
+ )
+
+ const sections = React.useMemo(() => {
+ return [
+ {
+ title: _(msg`Top`),
+ component: (
+
+ ),
+ },
+ {
+ title: _(msg`Latest`),
+ component: (
+
+ ),
+ },
+ ]
+ }, [_, topic, activeTab])
+
+ return (
+
+
+
+
+ {headerTitle}
+
+
+
+
+
+ (
+
+ section.title)} {...props} />
+
+ )}
+ initialPage={0}>
+ {sections.map((section, i) => (
+ {section.component}
+ ))}
+
+
+ )
+}
+
+function TopicScreenTab({
+ topic,
+ sort,
+ active,
+}: {
+ topic: string
+ sort: 'top' | 'latest'
+ active: boolean
+}) {
+ const {_} = useLingui()
+ const initialNumToRender = useInitialNumToRender()
+ const [isPTR, setIsPTR] = React.useState(false)
+
+ const {
+ data,
+ isFetched,
+ isFetchingNextPage,
+ isLoading,
+ isError,
+ error,
+ refetch,
+ fetchNextPage,
+ hasNextPage,
+ } = useSearchPostsQuery({
+ query: decodeURIComponent(topic),
+ sort,
+ enabled: active,
+ })
+
+ const posts = React.useMemo(() => {
+ return data?.pages.flatMap(page => page.posts) || []
+ }, [data])
+
+ const onRefresh = React.useCallback(async () => {
+ setIsPTR(true)
+ await refetch()
+ setIsPTR(false)
+ }, [refetch])
+
+ const onEndReached = React.useCallback(() => {
+ if (isFetchingNextPage || !hasNextPage || error) return
+ fetchNextPage()
+ }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
+
+ return (
+ <>
+ {posts.length < 1 ? (
+
+ ) : (
+
+ }
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
+ />
+ )}
+ >
+ )
+}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index f70d774630..0a9e5b2c07 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -125,6 +125,7 @@ const schema = z.object({
subtitlesEnabled: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
+ trendingDisabled: z.boolean().optional(),
})
export type Schema = z.infer
@@ -170,6 +171,7 @@ export const defaults: Schema = {
kawaii: false,
hasCheckedForStarterPack: false,
subtitlesEnabled: true,
+ trendingDisabled: false,
}
export function tryParse(rawData: string): Schema | undefined {
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index c7eaf27261..8530a8d0c8 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -10,6 +10,7 @@ import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages'
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
import {Provider as SubtitlesProvider} from './subtitles'
+import {Provider as TrendingSettingsProvider} from './trending'
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
export {
@@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
- {children}
+
+ {children}
+
diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx
new file mode 100644
index 0000000000..bf5d8f13cc
--- /dev/null
+++ b/src/state/preferences/trending.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = {
+ trendingDisabled: Exclude
+}
+type ApiContext = {
+ setTrendingDisabled(
+ hidden: Exclude,
+ ): void
+}
+
+const StateContext = React.createContext({
+ trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
+})
+const ApiContext = React.createContext({
+ setTrendingDisabled() {},
+})
+
+function usePersistedBooleanValue(key: T) {
+ const [value, _set] = React.useState(() => {
+ return Boolean(persisted.get(key))
+ })
+ const set = React.useCallback<
+ (value: Exclude) => void
+ >(
+ hidden => {
+ _set(Boolean(hidden))
+ persisted.write(key, hidden)
+ },
+ [key, _set],
+ )
+ React.useEffect(() => {
+ return persisted.onUpdate(key, hidden => {
+ _set(Boolean(hidden))
+ })
+ }, [key, _set])
+
+ return [value, set] as const
+}
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [trendingDisabled, setTrendingDisabled] =
+ usePersistedBooleanValue('trendingDisabled')
+
+ /*
+ * Context
+ */
+ const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
+ const api = React.useMemo(
+ () => ({setTrendingDisabled}),
+ [setTrendingDisabled],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTrendingSettings() {
+ return React.useContext(StateContext)
+}
+
+export function useTrendingSettingsApi() {
+ return React.useContext(ApiContext)
+}
diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts
index 0635bf316b..d4b9d94c45 100644
--- a/src/state/queries/index.ts
+++ b/src/state/queries/index.ts
@@ -6,6 +6,7 @@ export const STALE = {
MINUTES: {
ONE: 1e3 * 60,
FIVE: 1e3 * 60 * 5,
+ THIRTY: 1e3 * 60 * 30,
},
HOURS: {
ONE: 1e3 * 60 * 60,
diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts
new file mode 100644
index 0000000000..9a9db78659
--- /dev/null
+++ b/src/state/queries/service-config.ts
@@ -0,0 +1,32 @@
+import {useQuery} from '@tanstack/react-query'
+
+import {STALE} from '#/state/queries'
+import {useAgent} from '#/state/session'
+
+type ServiceConfig = {
+ checkEmailConfirmed: boolean
+ topicsEnabled: boolean
+}
+
+export function useServiceConfigQuery() {
+ const agent = useAgent()
+ return useQuery({
+ refetchOnWindowFocus: true,
+ staleTime: STALE.MINUTES.FIVE,
+ queryKey: ['service-config'],
+ queryFn: async () => {
+ try {
+ const {data} = await agent.api.app.bsky.unspecced.getConfig()
+ return {
+ checkEmailConfirmed: Boolean(data.checkEmailConfirmed),
+ topicsEnabled: Boolean(data.topicsEnabled),
+ }
+ } catch (e) {
+ return {
+ checkEmailConfirmed: false,
+ topicsEnabled: false,
+ }
+ }
+ },
+ })
+}
diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts
new file mode 100644
index 0000000000..310f64e9f2
--- /dev/null
+++ b/src/state/queries/trending/useTrendingTopics.ts
@@ -0,0 +1,49 @@
+import React from 'react'
+import {AppBskyUnspeccedDefs} from '@atproto/api'
+import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords'
+import {useQuery} from '@tanstack/react-query'
+
+import {STALE} from '#/state/queries'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAgent} from '#/state/session'
+
+export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic
+
+export const DEFAULT_LIMIT = 14
+
+export const trendingTopicsQueryKey = ['trending-topics']
+
+export function useTrendingTopics() {
+ const agent = useAgent()
+ const {data: preferences} = usePreferencesQuery()
+ const mutedWords = React.useMemo(() => {
+ return preferences?.moderationPrefs?.mutedWords || []
+ }, [preferences?.moderationPrefs])
+
+ return useQuery({
+ refetchOnWindowFocus: true,
+ staleTime: STALE.MINUTES.THIRTY,
+ queryKey: trendingTopicsQueryKey,
+ async queryFn() {
+ const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({
+ limit: DEFAULT_LIMIT,
+ })
+
+ const {topics, suggested} = data
+ return {
+ topics: topics.filter(t => {
+ return !hasMutedWord({
+ mutedWords,
+ text: t.topic + ' ' + t.displayName + ' ' + t.description,
+ })
+ }),
+ suggested: suggested.filter(t => {
+ return !hasMutedWord({
+ mutedWords,
+ text: t.topic + ' ' + t.displayName + ' ' + t.description,
+ })
+ }),
+ }
+ },
+ })
+}
diff --git a/src/state/trending-config.tsx b/src/state/trending-config.tsx
new file mode 100644
index 0000000000..a7694993fb
--- /dev/null
+++ b/src/state/trending-config.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+
+import {useGate} from '#/lib/statsig/statsig'
+import {useLanguagePrefs} from '#/state/preferences/languages'
+import {useServiceConfigQuery} from '#/state/queries/service-config'
+import {device} from '#/storage'
+
+type Context = {
+ enabled: boolean
+}
+
+const Context = React.createContext({
+ enabled: false,
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const gate = useGate()
+ const langPrefs = useLanguagePrefs()
+ const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
+ const ctx = React.useMemo(() => {
+ if (__DEV__) {
+ return {enabled: true}
+ }
+
+ /*
+ * Only English during beta period
+ */
+ if (
+ !!langPrefs.contentLanguages.length &&
+ !langPrefs.contentLanguages.includes('en')
+ ) {
+ return {enabled: false}
+ }
+
+ /*
+ * While loading, use cached value
+ */
+ const cachedEnabled = device.get(['trendingBetaEnabled'])
+ if (isInitialLoad) {
+ return {enabled: Boolean(cachedEnabled)}
+ }
+
+ /*
+ * Doing an extra check here to reduce hits to statsig. If it's disabled on
+ * the server, we can exit early.
+ */
+ const enabled = Boolean(config?.topicsEnabled)
+ if (!enabled) {
+ // cache for next reload
+ device.set(['trendingBetaEnabled'], enabled)
+ return {enabled: false}
+ }
+
+ /*
+ * Service is enabled, but also check statsig in case we're rolling back.
+ */
+ const gateEnabled = gate('trending_topics_beta')
+ const _enabled = enabled && gateEnabled
+
+ // update cache
+ device.set(['trendingBetaEnabled'], _enabled)
+
+ return {enabled: _enabled}
+ }, [isInitialLoad, config, gate, langPrefs.contentLanguages])
+ return {children}
+}
+
+export function useTrendingConfig() {
+ return React.useContext(Context)
+}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index cf410c77de..cfca9131c7 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -8,4 +8,5 @@ export type Device = {
geolocation?: {
countryCode: string | undefined
}
+ trendingBetaEnabled: boolean
}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 10eb47d0a4..7860d568d5 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -23,6 +23,7 @@ import {logger} from '#/logger'
import {isIOS, isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {useTrendingSettings} from '#/state/preferences/trending'
import {STALE} from '#/state/queries'
import {
FeedDescriptor,
@@ -34,7 +35,9 @@ import {
} from '#/state/queries/post-feed'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
+import {useBreakpoints} from '#/alf'
import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials'
+import {TrendingInterstitial} from '#/components/interstitials/Trending'
import {List, ListRef} from '../util/List'
import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
@@ -90,6 +93,10 @@ type FeedRow =
type: 'interstitialProgressGuide'
key: string
}
+ | {
+ type: 'interstitialTrending'
+ key: string
+ }
export function getFeedPostSlice(feedRow: FeedRow): FeedPostSlice | null {
if (feedRow.type === 'sliceItem') {
@@ -156,6 +163,7 @@ let PostFeed = ({
const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef(Date.now())
const [feedType, feedUri, feedTab] = feed.split('|')
+ const {gtTablet} = useBreakpoints()
const opts = React.useMemo(
() => ({enabled, ignoreFilterFor}),
@@ -259,6 +267,8 @@ let PostFeed = ({
const showProgressIntersitial =
(followProgressGuide || followAndLikeProgressGuide) && !isDesktop
+ const {trendingDisabled} = useTrendingSettings()
+
const feedItems: FeedRow[] = React.useMemo(() => {
let feedKind: 'following' | 'discover' | 'profile' | undefined
if (feedType === 'following') {
@@ -304,7 +314,16 @@ let PostFeed = ({
type: 'interstitialProgressGuide',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
})
- } else if (sliceIndex === 20) {
+ } else if (
+ sliceIndex === 15 &&
+ !gtTablet &&
+ !trendingDisabled
+ ) {
+ arr.push({
+ type: 'interstitialTrending',
+ key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
+ })
+ } else if (sliceIndex === 30) {
arr.push({
type: 'interstitialFollows',
key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt,
@@ -390,6 +409,8 @@ let PostFeed = ({
feedTab,
hasSession,
showProgressIntersitial,
+ trendingDisabled,
+ gtTablet,
])
// events
@@ -476,6 +497,8 @@ let PostFeed = ({
return
} else if (row.type === 'interstitialProgressGuide') {
return
+ } else if (row.type === 'interstitialTrending') {
+ return
} else if (row.type === 'sliceItem') {
const slice = row.slice
if (slice.isFallbackMarker) {
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index bd2ebe5d5d..378ea59a4d 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -24,6 +24,8 @@ import {
ProfileCardFeedLoadingPlaceholder,
} from '#/view/com/util/LoadingPlaceholder'
import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations'
+import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
@@ -239,6 +241,14 @@ type ExploreScreenItems =
style?: ViewStyleProp['style']
icon: React.ComponentType
}
+ | {
+ type: 'trendingTopics'
+ key: string
+ }
+ | {
+ type: 'recommendations'
+ key: string
+ }
| {
type: 'profile'
key: string
@@ -325,17 +335,27 @@ export function Explore() {
])
const items = React.useMemo(() => {
- const i: ExploreScreenItems[] = [
- {
- type: 'header',
- key: 'suggested-follows-header',
- title: _(msg`Suggested accounts`),
- description: _(
- msg`Follow more accounts to get connected to your interests and build your network.`,
- ),
- icon: Person,
- },
- ]
+ const i: ExploreScreenItems[] = []
+
+ i.push({
+ type: 'trendingTopics',
+ key: `trending-topics`,
+ })
+
+ i.push({
+ type: 'recommendations',
+ key: `recommendations`,
+ })
+
+ i.push({
+ type: 'header',
+ key: 'suggested-follows-header',
+ title: _(msg`Suggested accounts`),
+ description: _(
+ msg`Follow more accounts to get connected to your interests and build your network.`,
+ ),
+ icon: Person,
+ })
if (profiles) {
// Currently the responses contain duplicate items.
@@ -490,6 +510,12 @@ export function Explore() {
/>
)
}
+ case 'trendingTopics': {
+ return
+ }
+ case 'recommendations': {
+ return
+ }
case 'profile': {
return (
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index 83b5420ce7..1d515df558 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -14,7 +14,7 @@ import {createStaticClick, InlineLinkText} from '#/components/Link'
export function DesktopFeeds() {
const t = useTheme()
const {_} = useLingui()
- const {data: pinnedFeedInfos} = usePinnedFeedsInfos()
+ const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos()
const selectedFeed = useSelectedFeed()
const setSelectedFeed = useSetSelectedFeed()
const navigation = useNavigation()
@@ -25,14 +25,40 @@ export function DesktopFeeds() {
return getCurrentRoute(state)
})
- if (!pinnedFeedInfos) {
+ if (isLoading) {
+ return (
+
+ {Array(5)
+ .fill(0)
+ .map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ if (error || !pinnedFeedInfos) {
return null
}
return (
>({})
+
+ React.useEffect(() => {
+ return navigation.addListener('state', e => {
+ try {
+ const {state} = e.data
+ const lastRoute = state.routes[state.routes.length - 1]
+ const {params} = lastRoute
+ setParams(params)
+ } catch (e) {}
+ })
+ }, [navigation, setParams])
+
+ return params
+}
+
export function DesktopRightNav({routeName}: {routeName: string}) {
const t = useTheme()
const {_} = useLingui()
const {hasSession, currentAccount} = useSession()
const kawaii = useKawaiiMode()
const gutters = useGutters(['base', 0, 'base', 'wide'])
+ const isSearchScreen = routeName === 'Search'
+ const webqueryParams = useWebQueryParams()
+ const searchQuery = webqueryParams?.q
+ const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery)
const {isTablet} = useWebMediaQueries()
if (isTablet) {
@@ -29,6 +55,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
- {routeName !== 'Search' && (
-
-
-
- )}
+ {!isSearchScreen && }
+
{hasSession && (
<>
-
-
-
-
+
+
+
>
)}
+ {showTrending && }
+
{hasSession && (
<>
diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx
new file mode 100644
index 0000000000..e22fad54d7
--- /dev/null
+++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx
@@ -0,0 +1,104 @@
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {
+ useTrendingSettings,
+ useTrendingSettingsApi,
+} from '#/state/preferences/trending'
+import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics'
+import {useTrendingConfig} from '#/state/trending-config'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
+import * as Prompt from '#/components/Prompt'
+import {
+ TrendingTopic,
+ TrendingTopicLink,
+ TrendingTopicSkeleton,
+} from '#/components/TrendingTopics'
+import {Text} from '#/components/Typography'
+
+const TRENDING_LIMIT = 6
+
+export function SidebarTrendingTopics() {
+ const {enabled} = useTrendingConfig()
+ const {trendingDisabled} = useTrendingSettings()
+ return !enabled ? null : trendingDisabled ? null :
+}
+
+function Inner() {
+ const t = useTheme()
+ const {_} = useLingui()
+ const trendingPrompt = Prompt.usePromptControl()
+ const {setTrendingDisabled} = useTrendingSettingsApi()
+ const {data: trending, error, isLoading} = useTrendingTopics()
+ const noTopics = !isLoading && !error && !trending?.topics?.length
+
+ return error || noTopics ? null : (
+ <>
+
+
+
+
+ Trending
+
+
+
+
+
+ {isLoading ? (
+ Array(TRENDING_LIMIT)
+ .fill(0)
+ .map((_n, i) => (
+
+ ))
+ ) : !trending?.topics ? null : (
+ <>
+ {trending.topics.slice(0, TRENDING_LIMIT).map(topic => (
+
+ {({hovered}) => (
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ setTrendingDisabled(true)}
+ />
+
+ >
+ )
+}
diff --git a/yarn.lock b/yarn.lock
index f6598f8b8d..61ed0f66c7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -72,6 +72,20 @@
tlds "^1.234.0"
zod "^3.23.8"
+"@atproto/api@^0.13.21":
+ version "0.13.21"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc"
+ integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA==
+ dependencies:
+ "@atproto/common-web" "^0.3.1"
+ "@atproto/lexicon" "^0.4.4"
+ "@atproto/syntax" "^0.3.1"
+ "@atproto/xrpc" "^0.6.5"
+ await-lock "^2.2.2"
+ multiformats "^9.9.0"
+ tlds "^1.234.0"
+ zod "^3.23.8"
+
"@atproto/aws@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4"
@@ -3247,7 +3261,7 @@
"@babel/parser" "^7.25.9"
"@babel/types" "^7.25.9"
-"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
+"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
@@ -3308,19 +3322,6 @@
debug "^4.3.1"
globals "^11.1.0"
-"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
- version "7.25.9"
- resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
- integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
- dependencies:
- "@babel/code-frame" "^7.25.9"
- "@babel/generator" "^7.25.9"
- "@babel/parser" "^7.25.9"
- "@babel/template" "^7.25.9"
- "@babel/types" "^7.25.9"
- debug "^4.3.1"
- globals "^11.1.0"
-
"@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
@@ -17456,16 +17457,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17565,7 +17557,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17579,13 +17571,6 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -18860,7 +18845,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -18878,15 +18863,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"