diff options
author | Eric Bailey <git@esb.lol> | 2024-12-17 21:45:39 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-17 19:45:39 -0800 |
commit | a2019aceec001e276272832b97ea5e2ec864c8a5 (patch) | |
tree | eaddab8a7a009650d93bb3b49c750619d98bb44d /src | |
parent | a07949ec8e63bae178a829f65c33fcd9622b28ec (diff) | |
download | voidsky-a2019aceec001e276272832b97ea5e2ec864c8a5.tar.zst |
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 1358ad47fdf7e633749340c410933b508af46c10. * 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>
Diffstat (limited to 'src')
26 files changed, 1248 insertions, 37 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 39ab7ca92..780295ddc 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() { <BackgroundNotificationPreferencesProvider> <MutedThreadsProvider> <ProgressGuideProvider> - <GestureHandlerRootView - style={s.h100pct}> - <TestCtrls /> - <Shell /> - <NuxDialogs /> - </GestureHandlerRootView> + <TrendingConfigProvider> + <GestureHandlerRootView + style={s.h100pct}> + <TestCtrls /> + <Shell /> + <NuxDialogs /> + </GestureHandlerRootView> + </TrendingConfigProvider> </ProgressGuideProvider> </MutedThreadsProvider> </BackgroundNotificationPreferencesProvider> diff --git a/src/App.web.tsx b/src/App.web.tsx index 8d13a826e..8a2e13600 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() { <MutedThreadsProvider> <SafeAreaProvider> <ProgressGuideProvider> - <Shell /> - <NuxDialogs /> + <TrendingConfigProvider> + <Shell /> + <NuxDialogs /> + </TrendingConfigProvider> </ProgressGuideProvider> </SafeAreaProvider> </MutedThreadsProvider> diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7443128d2..18705c5ff 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<AllNavigatorParams>() @@ -377,6 +378,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { options={{title: title(msg`Hashtag`)}} /> <Stack.Screen + name="Topic" + getComponent={() => TopicScreen} + options={{title: title(msg`Topic`)}} + /> + <Stack.Screen name="MessagesConversation" getComponent={() => MessagesConversationScreen} options={{title: title(msg`Chat`), requireAuth: true}} diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx index 3dff404d7..9ad6ed7dc 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 000000000..6881f24bd --- /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 ( + <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]) +} diff --git a/src/components/interstitials/Trending.tsx b/src/components/interstitials/Trending.tsx new file mode 100644 index 000000000..3944d92f0 --- /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 ? <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> + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 238e4be4c..d720886e9 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 a6c249254..455a70345 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 188665d84..7cd7c0880 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 000000000..e253cfb5a --- /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 ? <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> + </> + ) +} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx new file mode 100644 index 000000000..be347dcd4 --- /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 ? <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> + </> + ) +} diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index 17f8fa506..bdbe1d191 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 ( <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> diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx new file mode 100644 index 000000000..6cd69f05f --- /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<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} + /> + )} + </> + ) +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f70d77463..0a9e5b2c0 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<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 { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c7eaf2726..8530a8d0c 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<{}>) { <AutoplayProvider> <UsedStarterPacksProvider> <SubtitlesProvider> - <KawaiiProvider>{children}</KawaiiProvider> + <TrendingSettingsProvider> + <KawaiiProvider>{children}</KawaiiProvider> + </TrendingSettingsProvider> </SubtitlesProvider> </UsedStarterPacksProvider> </AutoplayProvider> diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx new file mode 100644 index 000000000..bf5d8f13c --- /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<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) +} diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index 0635bf316..d4b9d94c4 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 000000000..9a9db7865 --- /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<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, + } + } + }, + }) +} diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts new file mode 100644 index 000000000..310f64e9f --- /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 000000000..a7694993f --- /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<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) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index cf410c77d..cfca9131c 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 10eb47d0a..7860d568d 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<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) { diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx index bd2ebe5d5..378ea59a4 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' @@ -240,6 +242,14 @@ type ExploreScreenItems = icon: React.ComponentType<SVGIconProps> } | { + type: 'trendingTopics' + key: string + } + | { + type: 'recommendations' + key: string + } + | { type: 'profile' key: string profile: AppBskyActorDefs.ProfileView @@ -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]}> diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 83b5420ce..1d515df55 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<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, /* diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 895d16021..363294aa5 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -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 && ( <> diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx new file mode 100644 index 000000000..e22fad54d --- /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 : <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 /> + </> + ) +} |