Trending (Beta) (#7144)
* Add WIP UIs for trending topics and suggested starterpacks
* Disable SPs for now
* Improve explore treatment a bit, add some polish to cards
* Add tiny option in RightNav
* Add persisted option to hide trending from sidebar
* Add to settings, abstract state, not updating in tab
* Fix up hide/show toggle state, WITH broadcast hacK
* Clean up persisted code, add new setting
* Add new interstitial to Discover
* Exploration
* First hack at mute words
* Wire up interstitial and Explore page
* Align components
* Some skeleton UI
* Handle service config, enablement, load states, update lex contract
* Centralize mute word handling
* Stale time to 30m
* Cache enabled value for reloads, use real data for service config
* Remove broadcast hack
* Remove titleChild
* Gate settings too
* Update package, rm langs
* Add feature gate
* Only english during beta period
* Hook up real data
* Tweak config
* Straight passthrough links
* Hook up prod agent
* Fix no-show logic
* Up config query to 5 min
* Remove old file
* Remove comment
* Remove stray flex_1
* Make trending setting global
* Quick placeholder state
* Limit # in sidebar, tweak spacing
* Tweak gaps
* Handle hide/show of sidebar
* Simplify messages
* Remove interstitial
* Revert "Remove interstitial"
This reverts commit 1358ad47fd.
* Only show interstitial on mobile
* Fix gap
* Add explore page recommendations
* [topics] add topic screen (#7149)
* add topic screen
* decode
* fix search query
* decode
* add server route
* Fix potential bad destructure (undefined)
---------
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+9
-6
@@ -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() {
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</GestureHandlerRootView>
|
||||
<TrendingConfigProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</GestureHandlerRootView>
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
|
||||
+5
-2
@@ -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() {
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<TrendingConfigProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</TrendingConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
|
||||
@@ -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<AllNavigatorParams>()
|
||||
|
||||
@@ -376,6 +377,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => HashtagScreen}
|
||||
options={{title: title(msg`Hashtag`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Topic"
|
||||
getComponent={() => TopicScreen}
|
||||
options={{title: title(msg`Topic`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesConversation"
|
||||
getComponent={() => MessagesConversationScreen}
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg,
|
||||
isSmall
|
||||
? [
|
||||
{
|
||||
paddingVertical: 5,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
]
|
||||
: [a.py_sm, a.px_md],
|
||||
style,
|
||||
/*
|
||||
{
|
||||
padding: 6,
|
||||
gap: hasAvi ? 4 : 2,
|
||||
},
|
||||
a.pr_md,
|
||||
*/
|
||||
]}>
|
||||
{/*
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
width: aviSize,
|
||||
height: aviSize,
|
||||
},
|
||||
]}>
|
||||
{topic.type === 'tag' ? (
|
||||
<Hashtag width={iconSize} />
|
||||
) : topic.type === 'topic' ? (
|
||||
<Quote width={iconSize - 2} />
|
||||
) : topic.type === 'feed' ? (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={aviSize}
|
||||
avatar=""
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
*/}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}],
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{topic.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrendingTopicSkeleton({
|
||||
size = 'large',
|
||||
index = 0,
|
||||
}: {
|
||||
size?: 'large' | 'small'
|
||||
index?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const isSmall = size === 'small'
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
t.atoms.bg_contrast_25,
|
||||
isSmall
|
||||
? {
|
||||
width: index % 2 === 0 ? 75 : 90,
|
||||
height: 27,
|
||||
}
|
||||
: {
|
||||
width: index % 2 === 0 ? 90 : 110,
|
||||
height: 36,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrendingTopicLink({
|
||||
topic: raw,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
topic: TrendingTopic
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const topic = useTopic(raw)
|
||||
|
||||
return (
|
||||
<InternalLink label={topic.label} to={topic.url} {...rest}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
@@ -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 ? <Inner /> : 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 : (
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.gap_lg,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Graph size="lg" />
|
||||
<Text style={[a.text_lg, a.font_heavy]}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
|
||||
<GradientFill gradient={tokens.gradients.primary} />
|
||||
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
|
||||
<Trans>BETA</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Hide trending topics`)}
|
||||
size="tiny"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => trendingPrompt.open()}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, {rowGap: 8, columnGap: 6}]}>
|
||||
{isLoading ? (
|
||||
Array(TRENDING_TOPICS_COUNT)
|
||||
.fill(0)
|
||||
.map((_n, i) => <TrendingTopicSkeleton key={i} index={i} />)
|
||||
) : !trending?.topics ? null : (
|
||||
<>
|
||||
{trending.topics.map(topic => (
|
||||
<TrendingTopicLink key={topic.link} topic={topic}>
|
||||
{({hovered}) => (
|
||||
<TrendingTopic
|
||||
topic={topic}
|
||||
style={[
|
||||
hovered && [
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={_(msg`Hide trending topics?`)}
|
||||
description={_(msg`You can update this later from your settings.`)}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
onConfirm={() => setTrendingDisabled(true)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -4,3 +4,4 @@ export type Gate =
|
||||
| 'debug_subscriptions'
|
||||
| 'new_postonboarding'
|
||||
| 'remove_show_latest_button'
|
||||
| 'trending_topics_beta'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ? <Inner /> : 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 : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
isWeb
|
||||
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
||||
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Hashtag_Stroke2_Corner0_Rounded
|
||||
size="lg"
|
||||
fill={t.palette.primary_500}
|
||||
style={{marginLeft: -2}}
|
||||
/>
|
||||
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
|
||||
<Trans>Recommended</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||
<Trans>Feeds we think you might like.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_md, a.pb_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.flex_wrap,
|
||||
{rowGap: 8, columnGap: 6},
|
||||
gutters,
|
||||
]}>
|
||||
{isLoading ? (
|
||||
Array(RECOMMENDATIONS_COUNT)
|
||||
.fill(0)
|
||||
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
|
||||
) : !trending?.suggested ? null : (
|
||||
<>
|
||||
{trending.suggested.map(topic => (
|
||||
<TrendingTopicLink key={topic.link} topic={topic}>
|
||||
{({hovered}) => (
|
||||
<TrendingTopic
|
||||
topic={topic}
|
||||
style={[
|
||||
hovered && [
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 ? <Inner /> : 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 : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
isWeb
|
||||
? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
|
||||
: [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md],
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Trending
|
||||
size="lg"
|
||||
fill={t.palette.primary_500}
|
||||
style={{marginLeft: -2}}
|
||||
/>
|
||||
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
|
||||
<GradientFill gradient={tokens.gradients.primary} />
|
||||
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
|
||||
<Trans>BETA</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||
<Trans>What people are posting about.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_md, a.pb_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.flex_wrap,
|
||||
{rowGap: 8, columnGap: 6},
|
||||
gutters,
|
||||
]}>
|
||||
{isLoading ? (
|
||||
Array(TRENDING_TOPICS_COUNT)
|
||||
.fill(0)
|
||||
.map((_, i) => <TrendingTopicSkeleton key={i} index={i} />)
|
||||
) : !trending?.topics ? null : (
|
||||
<>
|
||||
{trending.topics.map(topic => (
|
||||
<TrendingTopicLink key={topic.link} topic={topic}>
|
||||
{({hovered}) => (
|
||||
<TrendingTopic
|
||||
topic={topic}
|
||||
style={[
|
||||
hovered && [
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Layout.Screen>
|
||||
@@ -104,6 +113,24 @@ export function ContentAndMediaSettingsScreen({}: Props) {
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
{trendingEnabled && (
|
||||
<>
|
||||
<SettingsList.Divider />
|
||||
<Toggle.Item
|
||||
name="show_trending_topics"
|
||||
label={_(msg`Enable trending topics`)}
|
||||
value={!trendingDisabled}
|
||||
onChange={value => setTrendingDisabled(!value)}>
|
||||
<SettingsList.Item>
|
||||
<SettingsList.ItemIcon icon={Graph} />
|
||||
<SettingsList.ItemText>
|
||||
<Trans>Enable trending topics</Trans>
|
||||
</SettingsList.ItemText>
|
||||
<Toggle.Platform />
|
||||
</SettingsList.Item>
|
||||
</Toggle.Item>
|
||||
</>
|
||||
)}
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
</Layout.Screen>
|
||||
|
||||
@@ -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<PostView>) => {
|
||||
return <Post post={item} />
|
||||
}
|
||||
|
||||
const keyExtractor = (item: PostView, index: number) => {
|
||||
return `${item.uri}-${index}`
|
||||
}
|
||||
|
||||
export default function TopicScreen({
|
||||
route,
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) {
|
||||
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: (
|
||||
<TopicScreenTab topic={topic} sort="top" active={activeTab === 0} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: _(msg`Latest`),
|
||||
component: (
|
||||
<TopicScreenTab
|
||||
topic={topic}
|
||||
sort="latest"
|
||||
active={activeTab === 1}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [_, topic, activeTab])
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
<Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
|
||||
</Layout.Header.Content>
|
||||
<Layout.Header.Slot>
|
||||
<Button
|
||||
label={_(msg`Share`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
shape="round"
|
||||
onPress={onShare}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[{right: -3}]}>
|
||||
<ButtonIcon icon={Share} size="md" />
|
||||
</Button>
|
||||
</Layout.Header.Slot>
|
||||
</Layout.Header.Outer>
|
||||
<Pager
|
||||
onPageSelected={onPageSelected}
|
||||
renderTabBar={props => (
|
||||
<Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</Layout.Center>
|
||||
)}
|
||||
initialPage={0}>
|
||||
{sections.map((section, i) => (
|
||||
<View key={i}>{section.component}</View>
|
||||
))}
|
||||
</Pager>
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading || !isFetched}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
emptyType="results"
|
||||
emptyMessage={_(msg`We couldn't find any results for that topic.`)}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
data={posts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTR}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
// @ts-ignore web only -prf
|
||||
desktopFixedHeight
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
@@ -170,6 +171,7 @@ export const defaults: Schema = {
|
||||
kawaii: false,
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
trendingDisabled: false,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
|
||||
@@ -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<{}>) {
|
||||
<AutoplayProvider>
|
||||
<UsedStarterPacksProvider>
|
||||
<SubtitlesProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
<TrendingSettingsProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
</TrendingSettingsProvider>
|
||||
</SubtitlesProvider>
|
||||
</UsedStarterPacksProvider>
|
||||
</AutoplayProvider>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
|
||||
}
|
||||
type ApiContext = {
|
||||
setTrendingDisabled(
|
||||
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
|
||||
): void
|
||||
}
|
||||
|
||||
const StateContext = React.createContext<StateContext>({
|
||||
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
|
||||
})
|
||||
const ApiContext = React.createContext<ApiContext>({
|
||||
setTrendingDisabled() {},
|
||||
})
|
||||
|
||||
function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
|
||||
const [value, _set] = React.useState(() => {
|
||||
return Boolean(persisted.get(key))
|
||||
})
|
||||
const set = React.useCallback<
|
||||
(value: Exclude<persisted.Schema[T], undefined>) => 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 (
|
||||
<StateContext.Provider value={state}>
|
||||
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTrendingSettings() {
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
|
||||
export function useTrendingSettingsApi() {
|
||||
return React.useContext(ApiContext)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const STALE = {
|
||||
MINUTES: {
|
||||
ONE: 1e3 * 60,
|
||||
FIVE: 1e3 * 60 * 5,
|
||||
THIRTY: 1e3 * 60 * 30,
|
||||
},
|
||||
HOURS: {
|
||||
ONE: 1e3 * 60 * 60,
|
||||
|
||||
@@ -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<ServiceConfig>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<Context>({
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const gate = useGate()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
|
||||
const ctx = React.useMemo<Context>(() => {
|
||||
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 <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useTrendingConfig() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export type Device = {
|
||||
geolocation?: {
|
||||
countryCode: string | undefined
|
||||
}
|
||||
trendingBetaEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -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<number>(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 <SuggestedFollows feed={feed} />
|
||||
} else if (row.type === 'interstitialProgressGuide') {
|
||||
return <ProgressGuide />
|
||||
} else if (row.type === 'interstitialTrending') {
|
||||
return <TrendingInterstitial />
|
||||
} else if (row.type === 'sliceItem') {
|
||||
const slice = row.slice
|
||||
if (slice.isFallbackMarker) {
|
||||
|
||||
@@ -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<SVGIconProps>
|
||||
}
|
||||
| {
|
||||
type: 'trendingTopics'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'recommendations'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
@@ -325,17 +335,27 @@ export function Explore() {
|
||||
])
|
||||
|
||||
const items = React.useMemo<ExploreScreenItems[]>(() => {
|
||||
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 <ExploreTrendingTopics />
|
||||
}
|
||||
case 'recommendations': {
|
||||
return <ExploreRecommendations />
|
||||
}
|
||||
case 'profile': {
|
||||
return (
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]}>
|
||||
|
||||
@@ -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<NavigationProp>()
|
||||
@@ -25,14 +25,40 @@ export function DesktopFeeds() {
|
||||
return getCurrentRoute(state)
|
||||
})
|
||||
|
||||
if (!pinnedFeedInfos) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
gap: 12,
|
||||
},
|
||||
]}>
|
||||
{Array(5)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
height: 16,
|
||||
width: i % 2 === 0 ? '60%' : '80%',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !pinnedFeedInfos) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({
|
||||
gap: 10,
|
||||
/*
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
|
||||
import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
@@ -8,17 +10,41 @@ import {useKawaiiMode} from '#/state/preferences/kawaii'
|
||||
import {useSession} from '#/state/session'
|
||||
import {DesktopFeeds} from '#/view/shell/desktop/Feeds'
|
||||
import {DesktopSearch} from '#/view/shell/desktop/Search'
|
||||
import {SidebarTrendingTopics} from '#/view/shell/desktop/SidebarTrendingTopics'
|
||||
import {atoms as a, useGutters, useTheme, web} from '#/alf'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {ProgressGuideList} from '#/components/ProgressGuide/List'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function useWebQueryParams() {
|
||||
const navigation = useNavigation()
|
||||
const [params, setParams] = React.useState<Record<string, string>>({})
|
||||
|
||||
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}) {
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.gap_lg,
|
||||
web({
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
@@ -43,21 +70,18 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
overflowY: 'auto',
|
||||
}),
|
||||
]}>
|
||||
{routeName !== 'Search' && (
|
||||
<View style={[a.pb_lg]}>
|
||||
<DesktopSearch />
|
||||
</View>
|
||||
)}
|
||||
{!isSearchScreen && <DesktopSearch />}
|
||||
|
||||
{hasSession && (
|
||||
<>
|
||||
<ProgressGuideList style={[a.pb_xl]} />
|
||||
<View
|
||||
style={[a.pb_lg, a.mb_lg, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<DesktopFeeds />
|
||||
</View>
|
||||
<ProgressGuideList />
|
||||
<DesktopFeeds />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showTrending && <SidebarTrendingTopics />}
|
||||
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_low]}>
|
||||
{hasSession && (
|
||||
<>
|
||||
|
||||
@@ -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 : <Inner />
|
||||
}
|
||||
|
||||
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 : (
|
||||
<>
|
||||
<View style={[a.gap_sm, {paddingBottom: 2}]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Graph size="sm" />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Hide trending topics`)}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => trendingPrompt.open()}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.flex_wrap, {gap: '6px 4px'}]}>
|
||||
{isLoading ? (
|
||||
Array(TRENDING_LIMIT)
|
||||
.fill(0)
|
||||
.map((_n, i) => (
|
||||
<TrendingTopicSkeleton key={i} size="small" index={i} />
|
||||
))
|
||||
) : !trending?.topics ? null : (
|
||||
<>
|
||||
{trending.topics.slice(0, TRENDING_LIMIT).map(topic => (
|
||||
<TrendingTopicLink key={topic.link} topic={topic}>
|
||||
{({hovered}) => (
|
||||
<TrendingTopic
|
||||
size="small"
|
||||
topic={topic}
|
||||
style={[
|
||||
hovered && [
|
||||
t.atoms.border_contrast_high,
|
||||
t.atoms.bg_contrast_25,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={_(msg`Hide trending topics?`)}
|
||||
description={_(msg`You can update this later from your settings.`)}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
onConfirm={() => setTrendingDisabled(true)}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user