Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Eric Bailey
Paul Frazee
Dan Abramov
Hailey
and committed by
GitHub
a2019ace a07949ec

+1268 -80
+1
bskyweb/cmd/bskyweb/server.go
··· 235 235 236 236 // generic routes 237 237 e.GET("/hashtag/:tag", server.WebGeneric) 238 + e.GET("/topic/:topic", server.WebGeneric) 238 239 e.GET("/search", server.WebGeneric) 239 240 e.GET("/feeds", server.WebGeneric) 240 241 e.GET("/notifications", server.WebGeneric)
+1 -1
package.json
··· 54 54 "icons:optimize": "svgo -f ./assets/icons" 55 55 }, 56 56 "dependencies": { 57 - "@atproto/api": "^0.13.20", 57 + "@atproto/api": "^0.13.21", 58 58 "@bitdrift/react-native": "0.4.0", 59 59 "@braintree/sanitize-url": "^6.0.2", 60 60 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+9 -6
src/App.native.tsx
··· 57 57 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 58 58 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 59 59 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 60 + import {Provider as TrendingConfigProvider} from '#/state/trending-config' 60 61 import {TestCtrls} from '#/view/com/testing/TestCtrls' 61 62 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 62 63 import * as Toast from '#/view/com/util/Toast' ··· 143 144 <BackgroundNotificationPreferencesProvider> 144 145 <MutedThreadsProvider> 145 146 <ProgressGuideProvider> 146 - <GestureHandlerRootView 147 - style={s.h100pct}> 148 - <TestCtrls /> 149 - <Shell /> 150 - <NuxDialogs /> 151 - </GestureHandlerRootView> 147 + <TrendingConfigProvider> 148 + <GestureHandlerRootView 149 + style={s.h100pct}> 150 + <TestCtrls /> 151 + <Shell /> 152 + <NuxDialogs /> 153 + </GestureHandlerRootView> 154 + </TrendingConfigProvider> 152 155 </ProgressGuideProvider> 153 156 </MutedThreadsProvider> 154 157 </BackgroundNotificationPreferencesProvider>
+5 -2
src/App.web.tsx
··· 47 47 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 48 48 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 49 49 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 50 + import {Provider as TrendingConfigProvider} from '#/state/trending-config' 50 51 import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' 51 52 import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext' 52 53 import * as Toast from '#/view/com/util/Toast' ··· 127 128 <MutedThreadsProvider> 128 129 <SafeAreaProvider> 129 130 <ProgressGuideProvider> 130 - <Shell /> 131 - <NuxDialogs /> 131 + <TrendingConfigProvider> 132 + <Shell /> 133 + <NuxDialogs /> 134 + </TrendingConfigProvider> 132 135 </ProgressGuideProvider> 133 136 </SafeAreaProvider> 134 137 </MutedThreadsProvider>
+6
src/Navigation.tsx
··· 100 100 import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' 101 101 import {SettingsScreen} from './screens/Settings/Settings' 102 102 import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' 103 + import TopicScreen from './screens/Topic' 103 104 104 105 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 105 106 ··· 375 376 name="Hashtag" 376 377 getComponent={() => HashtagScreen} 377 378 options={{title: title(msg`Hashtag`)}} 379 + /> 380 + <Stack.Screen 381 + name="Topic" 382 + getComponent={() => TopicScreen} 383 + options={{title: title(msg`Topic`)}} 378 384 /> 379 385 <Stack.Screen 380 386 name="MessagesConversation"
+5 -3
src/components/GradientFill.tsx
··· 1 1 import {LinearGradient} from 'expo-linear-gradient' 2 2 3 - import {atoms as a, tokens} from '#/alf' 3 + import {atoms as a, tokens, ViewStyleProp} from '#/alf' 4 4 5 5 export function GradientFill({ 6 6 gradient, 7 - }: { 7 + style, 8 + }: ViewStyleProp & { 8 9 gradient: 10 + | typeof tokens.gradients.primary 9 11 | typeof tokens.gradients.sky 10 12 | typeof tokens.gradients.midnight 11 13 | typeof tokens.gradients.sunrise ··· 26 28 } 27 29 start={{x: 0, y: 0}} 28 30 end={{x: 1, y: 1}} 29 - style={[a.absolute, a.inset_0]} 31 + style={[a.absolute, a.inset_0, style]} 30 32 /> 31 33 ) 32 34 }
+223
src/components/TrendingTopics.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AtUri} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + // import {makeProfileLink} from '#/lib/routes/links' 8 + // import {feedUriToHref} from '#/lib/strings/url-helpers' 9 + // import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 10 + // import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote' 11 + // import {UserAvatar} from '#/view/com/util/UserAvatar' 12 + import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics' 13 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 14 + import {Link as InternalLink, LinkProps} from '#/components/Link' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function TrendingTopic({ 18 + topic: raw, 19 + size, 20 + style, 21 + }: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) { 22 + const t = useTheme() 23 + const topic = useTopic(raw) 24 + 25 + const isSmall = size === 'small' 26 + // const hasAvi = topic.type === 'feed' || topic.type === 'profile' 27 + // const aviSize = isSmall ? 16 : 20 28 + // const iconSize = isSmall ? 16 : 20 29 + 30 + return ( 31 + <View 32 + style={[ 33 + a.flex_row, 34 + a.align_center, 35 + a.rounded_full, 36 + a.border, 37 + t.atoms.border_contrast_medium, 38 + t.atoms.bg, 39 + isSmall 40 + ? [ 41 + { 42 + paddingVertical: 5, 43 + paddingHorizontal: 10, 44 + }, 45 + ] 46 + : [a.py_sm, a.px_md], 47 + style, 48 + /* 49 + { 50 + padding: 6, 51 + gap: hasAvi ? 4 : 2, 52 + }, 53 + a.pr_md, 54 + */ 55 + ]}> 56 + {/* 57 + <View 58 + style={[ 59 + a.align_center, 60 + a.justify_center, 61 + a.rounded_full, 62 + a.overflow_hidden, 63 + { 64 + width: aviSize, 65 + height: aviSize, 66 + }, 67 + ]}> 68 + {topic.type === 'tag' ? ( 69 + <Hashtag width={iconSize} /> 70 + ) : topic.type === 'topic' ? ( 71 + <Quote width={iconSize - 2} /> 72 + ) : topic.type === 'feed' ? ( 73 + <UserAvatar 74 + type="user" 75 + size={aviSize} 76 + avatar="" 77 + /> 78 + ) : ( 79 + <UserAvatar 80 + type="user" 81 + size={aviSize} 82 + avatar="" 83 + /> 84 + )} 85 + </View> 86 + */} 87 + 88 + <Text 89 + style={[ 90 + a.font_bold, 91 + a.leading_tight, 92 + isSmall ? [a.text_sm] : [a.text_md, {paddingBottom: 1}], 93 + ]} 94 + numberOfLines={1}> 95 + {topic.displayName} 96 + </Text> 97 + </View> 98 + ) 99 + } 100 + 101 + export function TrendingTopicSkeleton({ 102 + size = 'large', 103 + index = 0, 104 + }: { 105 + size?: 'large' | 'small' 106 + index?: number 107 + }) { 108 + const t = useTheme() 109 + const isSmall = size === 'small' 110 + return ( 111 + <View 112 + style={[ 113 + a.rounded_full, 114 + a.border, 115 + t.atoms.border_contrast_medium, 116 + t.atoms.bg_contrast_25, 117 + isSmall 118 + ? { 119 + width: index % 2 === 0 ? 75 : 90, 120 + height: 27, 121 + } 122 + : { 123 + width: index % 2 === 0 ? 90 : 110, 124 + height: 36, 125 + }, 126 + ]} 127 + /> 128 + ) 129 + } 130 + 131 + export function TrendingTopicLink({ 132 + topic: raw, 133 + children, 134 + ...rest 135 + }: { 136 + topic: TrendingTopic 137 + } & Omit<LinkProps, 'to' | 'label'>) { 138 + const topic = useTopic(raw) 139 + 140 + return ( 141 + <InternalLink label={topic.label} to={topic.url} {...rest}> 142 + {children} 143 + </InternalLink> 144 + ) 145 + } 146 + 147 + type ParsedTrendingTopic = 148 + | { 149 + type: 'topic' | 'tag' | 'unknown' 150 + label: string 151 + displayName: string 152 + url: string 153 + uri: undefined 154 + } 155 + | { 156 + type: 'profile' | 'feed' 157 + label: string 158 + displayName: string 159 + url: string 160 + uri: AtUri 161 + } 162 + 163 + export function useTopic(raw: TrendingTopic): ParsedTrendingTopic { 164 + const {_} = useLingui() 165 + return React.useMemo(() => { 166 + const {topic: displayName, link} = raw 167 + 168 + if (link.startsWith('/search')) { 169 + return { 170 + type: 'topic', 171 + label: _(msg`Browse posts about ${displayName}`), 172 + displayName, 173 + uri: undefined, 174 + url: link, 175 + } 176 + } else if (link.startsWith('/hashtag')) { 177 + return { 178 + type: 'tag', 179 + label: _(msg`Browse posts tagged with ${displayName}`), 180 + displayName, 181 + // displayName: displayName.replace(/^#/, ''), 182 + uri: undefined, 183 + url: link, 184 + } 185 + } 186 + 187 + /* 188 + if (!link.startsWith('at://')) { 189 + // above logic 190 + } else { 191 + const urip = new AtUri(link) 192 + switch (urip.collection) { 193 + case 'app.bsky.actor.profile': { 194 + return { 195 + type: 'profile', 196 + label: _(msg`View ${displayName}'s profile`), 197 + displayName, 198 + uri: urip, 199 + url: makeProfileLink({did: urip.host, handle: urip.host}), 200 + } 201 + } 202 + case 'app.bsky.feed.generator': { 203 + return { 204 + type: 'feed', 205 + label: _(msg`Browse the ${displayName} feed`), 206 + displayName, 207 + uri: urip, 208 + url: feedUriToHref(link), 209 + } 210 + } 211 + } 212 + } 213 + */ 214 + 215 + return { 216 + type: 'unknown', 217 + label: _(msg`Browse topic ${displayName}`), 218 + displayName, 219 + uri: undefined, 220 + url: link, 221 + } 222 + }, [_, raw]) 223 + }
+111
src/components/interstitials/Trending.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import { 6 + useTrendingSettings, 7 + useTrendingSettingsApi, 8 + } from '#/state/preferences/trending' 9 + import { 10 + DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, 11 + useTrendingTopics, 12 + } from '#/state/queries/trending/useTrendingTopics' 13 + import {useTrendingConfig} from '#/state/trending-config' 14 + import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 15 + import {Button, ButtonIcon} from '#/components/Button' 16 + import {GradientFill} from '#/components/GradientFill' 17 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 18 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 19 + import * as Prompt from '#/components/Prompt' 20 + import { 21 + TrendingTopic, 22 + TrendingTopicLink, 23 + TrendingTopicSkeleton, 24 + } from '#/components/TrendingTopics' 25 + import {Text} from '#/components/Typography' 26 + 27 + export function TrendingInterstitial() { 28 + const {enabled} = useTrendingConfig() 29 + const {trendingDisabled} = useTrendingSettings() 30 + return enabled && !trendingDisabled ? <Inner /> : null 31 + } 32 + 33 + export function Inner() { 34 + const t = useTheme() 35 + const {_} = useLingui() 36 + const gutters = useGutters(['wide', 'base']) 37 + const trendingPrompt = Prompt.usePromptControl() 38 + const {setTrendingDisabled} = useTrendingSettingsApi() 39 + const {data: trending, error, isLoading} = useTrendingTopics() 40 + const noTopics = !isLoading && !error && !trending?.topics?.length 41 + 42 + return error || noTopics ? null : ( 43 + <View 44 + style={[ 45 + gutters, 46 + a.gap_lg, 47 + a.border_t, 48 + t.atoms.border_contrast_low, 49 + t.atoms.bg_contrast_25, 50 + ]}> 51 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 52 + <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}> 53 + <Graph size="lg" /> 54 + <Text style={[a.text_lg, a.font_heavy]}> 55 + <Trans>Trending</Trans> 56 + </Text> 57 + <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> 58 + <GradientFill gradient={tokens.gradients.primary} /> 59 + <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> 60 + <Trans>BETA</Trans> 61 + </Text> 62 + </View> 63 + </View> 64 + 65 + <Button 66 + label={_(msg`Hide trending topics`)} 67 + size="tiny" 68 + variant="outline" 69 + color="secondary" 70 + shape="round" 71 + onPress={() => trendingPrompt.open()}> 72 + <ButtonIcon icon={X} /> 73 + </Button> 74 + </View> 75 + 76 + <View style={[a.flex_row, a.flex_wrap, {rowGap: 8, columnGap: 6}]}> 77 + {isLoading ? ( 78 + Array(TRENDING_TOPICS_COUNT) 79 + .fill(0) 80 + .map((_n, i) => <TrendingTopicSkeleton key={i} index={i} />) 81 + ) : !trending?.topics ? null : ( 82 + <> 83 + {trending.topics.map(topic => ( 84 + <TrendingTopicLink key={topic.link} topic={topic}> 85 + {({hovered}) => ( 86 + <TrendingTopic 87 + topic={topic} 88 + style={[ 89 + hovered && [ 90 + t.atoms.border_contrast_high, 91 + t.atoms.bg_contrast_25, 92 + ], 93 + ]} 94 + /> 95 + )} 96 + </TrendingTopicLink> 97 + ))} 98 + </> 99 + )} 100 + </View> 101 + 102 + <Prompt.Basic 103 + control={trendingPrompt} 104 + title={_(msg`Hide trending topics?`)} 105 + description={_(msg`You can update this later from your settings.`)} 106 + confirmButtonCta={_(msg`Hide`)} 107 + onConfirm={() => setTrendingDisabled(true)} 108 + /> 109 + </View> 110 + ) 111 + }
+3
src/lib/routes/types.ts
··· 47 47 AppIconSettings: undefined 48 48 Search: {q?: string} 49 49 Hashtag: {tag: string; author?: string} 50 + Topic: {topic: string} 50 51 MessagesConversation: {conversation: string; embed?: string} 51 52 MessagesSettings: undefined 52 53 NotificationSettings: undefined ··· 92 93 Feeds: undefined 93 94 Notifications: undefined 94 95 Hashtag: {tag: string; author?: string} 96 + Topic: {topic: string} 95 97 Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} 96 98 } 97 99 ··· 105 107 Notifications: undefined 106 108 MyProfileTab: undefined 107 109 Hashtag: {tag: string; author?: string} 110 + Topic: {topic: string} 108 111 MessagesTab: undefined 109 112 Messages: {animation?: 'push' | 'pop'} 110 113 Start: {name: string; rkey: string}
+1
src/lib/statsig/gates.ts
··· 4 4 | 'debug_subscriptions' 5 5 | 'new_postonboarding' 6 6 | 'remove_show_latest_button' 7 + | 'trending_topics_beta'
+1
src/routes.ts
··· 53 53 CopyrightPolicy: '/support/copyright', 54 54 // hashtags 55 55 Hashtag: '/hashtag/:tag', 56 + Topic: '/topic/:topic', 56 57 // DMs 57 58 Messages: '/messages', 58 59 MessagesSettings: '/messages/settings',
+95
src/screens/Search/components/ExploreRecommendations.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {isWeb} from '#/platform/detection' 5 + import {useTrendingSettings} from '#/state/preferences/trending' 6 + import { 7 + DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, 8 + useTrendingTopics, 9 + } from '#/state/queries/trending/useTrendingTopics' 10 + import {useTrendingConfig} from '#/state/trending-config' 11 + import {atoms as a, useGutters, useTheme} from '#/alf' 12 + import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' 13 + import { 14 + TrendingTopic, 15 + TrendingTopicLink, 16 + TrendingTopicSkeleton, 17 + } from '#/components/TrendingTopics' 18 + import {Text} from '#/components/Typography' 19 + 20 + export function ExploreRecommendations() { 21 + const {enabled} = useTrendingConfig() 22 + const {trendingDisabled} = useTrendingSettings() 23 + return enabled && !trendingDisabled ? <Inner /> : null 24 + } 25 + 26 + function Inner() { 27 + const t = useTheme() 28 + const gutters = useGutters([0, 'compact']) 29 + const {data: trending, error, isLoading} = useTrendingTopics() 30 + const noRecs = !isLoading && !error && !trending?.suggested?.length 31 + 32 + return error || noRecs ? null : ( 33 + <> 34 + <View 35 + style={[ 36 + isWeb 37 + ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 38 + : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], 39 + a.border_b, 40 + t.atoms.border_contrast_low, 41 + ]}> 42 + <View style={[a.flex_1, a.gap_sm]}> 43 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 44 + <Hashtag_Stroke2_Corner0_Rounded 45 + size="lg" 46 + fill={t.palette.primary_500} 47 + style={{marginLeft: -2}} 48 + /> 49 + <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> 50 + <Trans>Recommended</Trans> 51 + </Text> 52 + </View> 53 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 54 + <Trans>Feeds we think you might like.</Trans> 55 + </Text> 56 + </View> 57 + </View> 58 + 59 + <View style={[a.pt_md, a.pb_lg]}> 60 + <View 61 + style={[ 62 + a.flex_row, 63 + a.justify_start, 64 + a.flex_wrap, 65 + {rowGap: 8, columnGap: 6}, 66 + gutters, 67 + ]}> 68 + {isLoading ? ( 69 + Array(RECOMMENDATIONS_COUNT) 70 + .fill(0) 71 + .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) 72 + ) : !trending?.suggested ? null : ( 73 + <> 74 + {trending.suggested.map(topic => ( 75 + <TrendingTopicLink key={topic.link} topic={topic}> 76 + {({hovered}) => ( 77 + <TrendingTopic 78 + topic={topic} 79 + style={[ 80 + hovered && [ 81 + t.atoms.border_contrast_high, 82 + t.atoms.bg_contrast_25, 83 + ], 84 + ]} 85 + /> 86 + )} 87 + </TrendingTopicLink> 88 + ))} 89 + </> 90 + )} 91 + </View> 92 + </View> 93 + </> 94 + ) 95 + }
+102
src/screens/Search/components/ExploreTrendingTopics.tsx
··· 1 + import {View} from 'react-native' 2 + import {Trans} from '@lingui/macro' 3 + 4 + import {isWeb} from '#/platform/detection' 5 + import {useTrendingSettings} from '#/state/preferences/trending' 6 + import { 7 + DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, 8 + useTrendingTopics, 9 + } from '#/state/queries/trending/useTrendingTopics' 10 + import {useTrendingConfig} from '#/state/trending-config' 11 + import {atoms as a, tokens, useGutters, useTheme} from '#/alf' 12 + import {GradientFill} from '#/components/GradientFill' 13 + import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' 14 + import { 15 + TrendingTopic, 16 + TrendingTopicLink, 17 + TrendingTopicSkeleton, 18 + } from '#/components/TrendingTopics' 19 + import {Text} from '#/components/Typography' 20 + 21 + export function ExploreTrendingTopics() { 22 + const {enabled} = useTrendingConfig() 23 + const {trendingDisabled} = useTrendingSettings() 24 + return enabled && !trendingDisabled ? <Inner /> : null 25 + } 26 + 27 + function Inner() { 28 + const t = useTheme() 29 + const gutters = useGutters([0, 'compact']) 30 + const {data: trending, error, isLoading} = useTrendingTopics() 31 + const noTopics = !isLoading && !error && !trending?.topics?.length 32 + 33 + return error || noTopics ? null : ( 34 + <> 35 + <View 36 + style={[ 37 + isWeb 38 + ? [a.flex_row, a.px_lg, a.py_lg, a.pt_2xl, a.gap_md] 39 + : [{flexDirection: 'row-reverse'}, a.p_lg, a.pt_2xl, a.gap_md], 40 + a.border_b, 41 + t.atoms.border_contrast_low, 42 + ]}> 43 + <View style={[a.flex_1, a.gap_sm]}> 44 + <View style={[a.flex_row, a.align_center, a.gap_sm]}> 45 + <Trending 46 + size="lg" 47 + fill={t.palette.primary_500} 48 + style={{marginLeft: -2}} 49 + /> 50 + <Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}> 51 + <Trans>Trending</Trans> 52 + </Text> 53 + <View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}> 54 + <GradientFill gradient={tokens.gradients.primary} /> 55 + <Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}> 56 + <Trans>BETA</Trans> 57 + </Text> 58 + </View> 59 + </View> 60 + <Text style={[t.atoms.text_contrast_high, a.leading_snug]}> 61 + <Trans>What people are posting about.</Trans> 62 + </Text> 63 + </View> 64 + </View> 65 + 66 + <View style={[a.pt_md, a.pb_lg]}> 67 + <View 68 + style={[ 69 + a.flex_row, 70 + a.justify_start, 71 + a.flex_wrap, 72 + {rowGap: 8, columnGap: 6}, 73 + gutters, 74 + ]}> 75 + {isLoading ? ( 76 + Array(TRENDING_TOPICS_COUNT) 77 + .fill(0) 78 + .map((_, i) => <TrendingTopicSkeleton key={i} index={i} />) 79 + ) : !trending?.topics ? null : ( 80 + <> 81 + {trending.topics.map(topic => ( 82 + <TrendingTopicLink key={topic.link} topic={topic}> 83 + {({hovered}) => ( 84 + <TrendingTopic 85 + topic={topic} 86 + style={[ 87 + hovered && [ 88 + t.atoms.border_contrast_high, 89 + t.atoms.bg_contrast_25, 90 + ], 91 + ]} 92 + /> 93 + )} 94 + </TrendingTopicLink> 95 + ))} 96 + </> 97 + )} 98 + </View> 99 + </View> 100 + </> 101 + ) 102 + }
+27
src/screens/Settings/ContentAndMediaSettings.tsx
··· 9 9 useInAppBrowser, 10 10 useSetInAppBrowser, 11 11 } from '#/state/preferences/in-app-browser' 12 + import { 13 + useTrendingSettings, 14 + useTrendingSettingsApi, 15 + } from '#/state/preferences/trending' 16 + import {useTrendingConfig} from '#/state/trending-config' 12 17 import * as SettingsList from '#/screens/Settings/components/SettingsList' 13 18 import * as Toggle from '#/components/forms/Toggle' 14 19 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' ··· 16 21 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' 17 22 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' 18 23 import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' 24 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 19 25 import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' 20 26 import * as Layout from '#/components/Layout' 21 27 ··· 29 35 const setAutoplayDisabledPref = useSetAutoplayDisabled() 30 36 const inAppBrowserPref = useInAppBrowser() 31 37 const setUseInAppBrowser = useSetInAppBrowser() 38 + const {enabled: trendingEnabled} = useTrendingConfig() 39 + const {trendingDisabled} = useTrendingSettings() 40 + const {setTrendingDisabled} = useTrendingSettingsApi() 32 41 33 42 return ( 34 43 <Layout.Screen> ··· 104 113 <Toggle.Platform /> 105 114 </SettingsList.Item> 106 115 </Toggle.Item> 116 + {trendingEnabled && ( 117 + <> 118 + <SettingsList.Divider /> 119 + <Toggle.Item 120 + name="show_trending_topics" 121 + label={_(msg`Enable trending topics`)} 122 + value={!trendingDisabled} 123 + onChange={value => setTrendingDisabled(!value)}> 124 + <SettingsList.Item> 125 + <SettingsList.ItemIcon icon={Graph} /> 126 + <SettingsList.ItemText> 127 + <Trans>Enable trending topics</Trans> 128 + </SettingsList.ItemText> 129 + <Toggle.Platform /> 130 + </SettingsList.Item> 131 + </Toggle.Item> 132 + </> 133 + )} 107 134 </SettingsList.Container> 108 135 </Layout.Content> 109 136 </Layout.Screen>
+204
src/screens/Topic.tsx
··· 1 + import React from 'react' 2 + import {ListRenderItemInfo, View} from 'react-native' 3 + import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useFocusEffect} from '@react-navigation/native' 7 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 8 + 9 + import {HITSLOP_10} from '#/lib/constants' 10 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 11 + import {CommonNavigatorParams} from '#/lib/routes/types' 12 + import {shareUrl} from '#/lib/sharing' 13 + import {cleanError} from '#/lib/strings/errors' 14 + import {enforceLen} from '#/lib/strings/helpers' 15 + import {useSearchPostsQuery} from '#/state/queries/search-posts' 16 + import {useSetMinimalShellMode} from '#/state/shell' 17 + import {Pager} from '#/view/com/pager/Pager' 18 + import {TabBar} from '#/view/com/pager/TabBar' 19 + import {Post} from '#/view/com/post/Post' 20 + import {List} from '#/view/com/util/List' 21 + import {atoms as a, web} from '#/alf' 22 + import {Button, ButtonIcon} from '#/components/Button' 23 + import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 24 + import * as Layout from '#/components/Layout' 25 + import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 26 + 27 + const renderItem = ({item}: ListRenderItemInfo<PostView>) => { 28 + return <Post post={item} /> 29 + } 30 + 31 + const keyExtractor = (item: PostView, index: number) => { 32 + return `${item.uri}-${index}` 33 + } 34 + 35 + export default function TopicScreen({ 36 + route, 37 + }: NativeStackScreenProps<CommonNavigatorParams, 'Topic'>) { 38 + const {topic} = route.params 39 + const {_} = useLingui() 40 + 41 + const headerTitle = React.useMemo(() => { 42 + return enforceLen(decodeURIComponent(topic), 24, true, 'middle') 43 + }, [topic]) 44 + 45 + const onShare = React.useCallback(() => { 46 + const url = new URL('https://bsky.app') 47 + url.pathname = `/topic/${topic}` 48 + shareUrl(url.toString()) 49 + }, [topic]) 50 + 51 + const [activeTab, setActiveTab] = React.useState(0) 52 + const setMinimalShellMode = useSetMinimalShellMode() 53 + 54 + useFocusEffect( 55 + React.useCallback(() => { 56 + setMinimalShellMode(false) 57 + }, [setMinimalShellMode]), 58 + ) 59 + 60 + const onPageSelected = React.useCallback( 61 + (index: number) => { 62 + setMinimalShellMode(false) 63 + setActiveTab(index) 64 + }, 65 + [setMinimalShellMode], 66 + ) 67 + 68 + const sections = React.useMemo(() => { 69 + return [ 70 + { 71 + title: _(msg`Top`), 72 + component: ( 73 + <TopicScreenTab topic={topic} sort="top" active={activeTab === 0} /> 74 + ), 75 + }, 76 + { 77 + title: _(msg`Latest`), 78 + component: ( 79 + <TopicScreenTab 80 + topic={topic} 81 + sort="latest" 82 + active={activeTab === 1} 83 + /> 84 + ), 85 + }, 86 + ] 87 + }, [_, topic, activeTab]) 88 + 89 + return ( 90 + <Layout.Screen> 91 + <Layout.Header.Outer noBottomBorder> 92 + <Layout.Header.BackButton /> 93 + <Layout.Header.Content> 94 + <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText> 95 + </Layout.Header.Content> 96 + <Layout.Header.Slot> 97 + <Button 98 + label={_(msg`Share`)} 99 + size="small" 100 + variant="ghost" 101 + color="primary" 102 + shape="round" 103 + onPress={onShare} 104 + hitSlop={HITSLOP_10} 105 + style={[{right: -3}]}> 106 + <ButtonIcon icon={Share} size="md" /> 107 + </Button> 108 + </Layout.Header.Slot> 109 + </Layout.Header.Outer> 110 + <Pager 111 + onPageSelected={onPageSelected} 112 + renderTabBar={props => ( 113 + <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}> 114 + <TabBar items={sections.map(section => section.title)} {...props} /> 115 + </Layout.Center> 116 + )} 117 + initialPage={0}> 118 + {sections.map((section, i) => ( 119 + <View key={i}>{section.component}</View> 120 + ))} 121 + </Pager> 122 + </Layout.Screen> 123 + ) 124 + } 125 + 126 + function TopicScreenTab({ 127 + topic, 128 + sort, 129 + active, 130 + }: { 131 + topic: string 132 + sort: 'top' | 'latest' 133 + active: boolean 134 + }) { 135 + const {_} = useLingui() 136 + const initialNumToRender = useInitialNumToRender() 137 + const [isPTR, setIsPTR] = React.useState(false) 138 + 139 + const { 140 + data, 141 + isFetched, 142 + isFetchingNextPage, 143 + isLoading, 144 + isError, 145 + error, 146 + refetch, 147 + fetchNextPage, 148 + hasNextPage, 149 + } = useSearchPostsQuery({ 150 + query: decodeURIComponent(topic), 151 + sort, 152 + enabled: active, 153 + }) 154 + 155 + const posts = React.useMemo(() => { 156 + return data?.pages.flatMap(page => page.posts) || [] 157 + }, [data]) 158 + 159 + const onRefresh = React.useCallback(async () => { 160 + setIsPTR(true) 161 + await refetch() 162 + setIsPTR(false) 163 + }, [refetch]) 164 + 165 + const onEndReached = React.useCallback(() => { 166 + if (isFetchingNextPage || !hasNextPage || error) return 167 + fetchNextPage() 168 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 169 + 170 + return ( 171 + <> 172 + {posts.length < 1 ? ( 173 + <ListMaybePlaceholder 174 + isLoading={isLoading || !isFetched} 175 + isError={isError} 176 + onRetry={refetch} 177 + emptyType="results" 178 + emptyMessage={_(msg`We couldn't find any results for that topic.`)} 179 + /> 180 + ) : ( 181 + <List 182 + data={posts} 183 + renderItem={renderItem} 184 + keyExtractor={keyExtractor} 185 + refreshing={isPTR} 186 + onRefresh={onRefresh} 187 + onEndReached={onEndReached} 188 + onEndReachedThreshold={4} 189 + // @ts-ignore web only -prf 190 + desktopFixedHeight 191 + ListFooterComponent={ 192 + <ListFooter 193 + isFetchingNextPage={isFetchingNextPage} 194 + error={cleanError(error)} 195 + onRetry={fetchNextPage} 196 + /> 197 + } 198 + initialNumToRender={initialNumToRender} 199 + windowSize={11} 200 + /> 201 + )} 202 + </> 203 + ) 204 + }
+2
src/state/persisted/schema.ts
··· 125 125 subtitlesEnabled: z.boolean().optional(), 126 126 /** @deprecated */ 127 127 mutedThreads: z.array(z.string()), 128 + trendingDisabled: z.boolean().optional(), 128 129 }) 129 130 export type Schema = z.infer<typeof schema> 130 131 ··· 170 171 kawaii: false, 171 172 hasCheckedForStarterPack: false, 172 173 subtitlesEnabled: true, 174 + trendingDisabled: false, 173 175 } 174 176 175 177 export function tryParse(rawData: string): Schema | undefined {
+4 -1
src/state/preferences/index.tsx
··· 10 10 import {Provider as LanguagesProvider} from './languages' 11 11 import {Provider as LargeAltBadgeProvider} from './large-alt-badge' 12 12 import {Provider as SubtitlesProvider} from './subtitles' 13 + import {Provider as TrendingSettingsProvider} from './trending' 13 14 import {Provider as UsedStarterPacksProvider} from './used-starter-packs' 14 15 15 16 export { ··· 39 40 <AutoplayProvider> 40 41 <UsedStarterPacksProvider> 41 42 <SubtitlesProvider> 42 - <KawaiiProvider>{children}</KawaiiProvider> 43 + <TrendingSettingsProvider> 44 + <KawaiiProvider>{children}</KawaiiProvider> 45 + </TrendingSettingsProvider> 43 46 </SubtitlesProvider> 44 47 </UsedStarterPacksProvider> 45 48 </AutoplayProvider>
+69
src/state/preferences/trending.tsx
··· 1 + import React from 'react' 2 + 3 + import * as persisted from '#/state/persisted' 4 + 5 + type StateContext = { 6 + trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined> 7 + } 8 + type ApiContext = { 9 + setTrendingDisabled( 10 + hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>, 11 + ): void 12 + } 13 + 14 + const StateContext = React.createContext<StateContext>({ 15 + trendingDisabled: Boolean(persisted.defaults.trendingDisabled), 16 + }) 17 + const ApiContext = React.createContext<ApiContext>({ 18 + setTrendingDisabled() {}, 19 + }) 20 + 21 + function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) { 22 + const [value, _set] = React.useState(() => { 23 + return Boolean(persisted.get(key)) 24 + }) 25 + const set = React.useCallback< 26 + (value: Exclude<persisted.Schema[T], undefined>) => void 27 + >( 28 + hidden => { 29 + _set(Boolean(hidden)) 30 + persisted.write(key, hidden) 31 + }, 32 + [key, _set], 33 + ) 34 + React.useEffect(() => { 35 + return persisted.onUpdate(key, hidden => { 36 + _set(Boolean(hidden)) 37 + }) 38 + }, [key, _set]) 39 + 40 + return [value, set] as const 41 + } 42 + 43 + export function Provider({children}: React.PropsWithChildren<{}>) { 44 + const [trendingDisabled, setTrendingDisabled] = 45 + usePersistedBooleanValue('trendingDisabled') 46 + 47 + /* 48 + * Context 49 + */ 50 + const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled]) 51 + const api = React.useMemo( 52 + () => ({setTrendingDisabled}), 53 + [setTrendingDisabled], 54 + ) 55 + 56 + return ( 57 + <StateContext.Provider value={state}> 58 + <ApiContext.Provider value={api}>{children}</ApiContext.Provider> 59 + </StateContext.Provider> 60 + ) 61 + } 62 + 63 + export function useTrendingSettings() { 64 + return React.useContext(StateContext) 65 + } 66 + 67 + export function useTrendingSettingsApi() { 68 + return React.useContext(ApiContext) 69 + }
+1
src/state/queries/index.ts
··· 6 6 MINUTES: { 7 7 ONE: 1e3 * 60, 8 8 FIVE: 1e3 * 60 * 5, 9 + THIRTY: 1e3 * 60 * 30, 9 10 }, 10 11 HOURS: { 11 12 ONE: 1e3 * 60 * 60,
+32
src/state/queries/service-config.ts
··· 1 + import {useQuery} from '@tanstack/react-query' 2 + 3 + import {STALE} from '#/state/queries' 4 + import {useAgent} from '#/state/session' 5 + 6 + type ServiceConfig = { 7 + checkEmailConfirmed: boolean 8 + topicsEnabled: boolean 9 + } 10 + 11 + export function useServiceConfigQuery() { 12 + const agent = useAgent() 13 + return useQuery<ServiceConfig>({ 14 + refetchOnWindowFocus: true, 15 + staleTime: STALE.MINUTES.FIVE, 16 + queryKey: ['service-config'], 17 + queryFn: async () => { 18 + try { 19 + const {data} = await agent.api.app.bsky.unspecced.getConfig() 20 + return { 21 + checkEmailConfirmed: Boolean(data.checkEmailConfirmed), 22 + topicsEnabled: Boolean(data.topicsEnabled), 23 + } 24 + } catch (e) { 25 + return { 26 + checkEmailConfirmed: false, 27 + topicsEnabled: false, 28 + } 29 + } 30 + }, 31 + }) 32 + }
+49
src/state/queries/trending/useTrendingTopics.ts
··· 1 + import React from 'react' 2 + import {AppBskyUnspeccedDefs} from '@atproto/api' 3 + import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords' 4 + import {useQuery} from '@tanstack/react-query' 5 + 6 + import {STALE} from '#/state/queries' 7 + import {usePreferencesQuery} from '#/state/queries/preferences' 8 + import {useAgent} from '#/state/session' 9 + 10 + export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic 11 + 12 + export const DEFAULT_LIMIT = 14 13 + 14 + export const trendingTopicsQueryKey = ['trending-topics'] 15 + 16 + export function useTrendingTopics() { 17 + const agent = useAgent() 18 + const {data: preferences} = usePreferencesQuery() 19 + const mutedWords = React.useMemo(() => { 20 + return preferences?.moderationPrefs?.mutedWords || [] 21 + }, [preferences?.moderationPrefs]) 22 + 23 + return useQuery({ 24 + refetchOnWindowFocus: true, 25 + staleTime: STALE.MINUTES.THIRTY, 26 + queryKey: trendingTopicsQueryKey, 27 + async queryFn() { 28 + const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({ 29 + limit: DEFAULT_LIMIT, 30 + }) 31 + 32 + const {topics, suggested} = data 33 + return { 34 + topics: topics.filter(t => { 35 + return !hasMutedWord({ 36 + mutedWords, 37 + text: t.topic + ' ' + t.displayName + ' ' + t.description, 38 + }) 39 + }), 40 + suggested: suggested.filter(t => { 41 + return !hasMutedWord({ 42 + mutedWords, 43 + text: t.topic + ' ' + t.displayName + ' ' + t.description, 44 + }) 45 + }), 46 + } 47 + }, 48 + }) 49 + }
+70
src/state/trending-config.tsx
··· 1 + import React from 'react' 2 + 3 + import {useGate} from '#/lib/statsig/statsig' 4 + import {useLanguagePrefs} from '#/state/preferences/languages' 5 + import {useServiceConfigQuery} from '#/state/queries/service-config' 6 + import {device} from '#/storage' 7 + 8 + type Context = { 9 + enabled: boolean 10 + } 11 + 12 + const Context = React.createContext<Context>({ 13 + enabled: false, 14 + }) 15 + 16 + export function Provider({children}: React.PropsWithChildren<{}>) { 17 + const gate = useGate() 18 + const langPrefs = useLanguagePrefs() 19 + const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery() 20 + const ctx = React.useMemo<Context>(() => { 21 + if (__DEV__) { 22 + return {enabled: true} 23 + } 24 + 25 + /* 26 + * Only English during beta period 27 + */ 28 + if ( 29 + !!langPrefs.contentLanguages.length && 30 + !langPrefs.contentLanguages.includes('en') 31 + ) { 32 + return {enabled: false} 33 + } 34 + 35 + /* 36 + * While loading, use cached value 37 + */ 38 + const cachedEnabled = device.get(['trendingBetaEnabled']) 39 + if (isInitialLoad) { 40 + return {enabled: Boolean(cachedEnabled)} 41 + } 42 + 43 + /* 44 + * Doing an extra check here to reduce hits to statsig. If it's disabled on 45 + * the server, we can exit early. 46 + */ 47 + const enabled = Boolean(config?.topicsEnabled) 48 + if (!enabled) { 49 + // cache for next reload 50 + device.set(['trendingBetaEnabled'], enabled) 51 + return {enabled: false} 52 + } 53 + 54 + /* 55 + * Service is enabled, but also check statsig in case we're rolling back. 56 + */ 57 + const gateEnabled = gate('trending_topics_beta') 58 + const _enabled = enabled && gateEnabled 59 + 60 + // update cache 61 + device.set(['trendingBetaEnabled'], _enabled) 62 + 63 + return {enabled: _enabled} 64 + }, [isInitialLoad, config, gate, langPrefs.contentLanguages]) 65 + return <Context.Provider value={ctx}>{children}</Context.Provider> 66 + } 67 + 68 + export function useTrendingConfig() { 69 + return React.useContext(Context) 70 + }
+1
src/storage/schema.ts
··· 8 8 geolocation?: { 9 9 countryCode: string | undefined 10 10 } 11 + trendingBetaEnabled: boolean 11 12 }
+24 -1
src/view/com/posts/PostFeed.tsx
··· 23 23 import {isIOS, isWeb} from '#/platform/detection' 24 24 import {listenPostCreated} from '#/state/events' 25 25 import {useFeedFeedbackContext} from '#/state/feed-feedback' 26 + import {useTrendingSettings} from '#/state/preferences/trending' 26 27 import {STALE} from '#/state/queries' 27 28 import { 28 29 FeedDescriptor, ··· 34 35 } from '#/state/queries/post-feed' 35 36 import {useSession} from '#/state/session' 36 37 import {useProgressGuide} from '#/state/shell/progress-guide' 38 + import {useBreakpoints} from '#/alf' 37 39 import {ProgressGuide, SuggestedFollows} from '#/components/FeedInterstitials' 40 + import {TrendingInterstitial} from '#/components/interstitials/Trending' 38 41 import {List, ListRef} from '../util/List' 39 42 import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' 40 43 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' ··· 88 91 } 89 92 | { 90 93 type: 'interstitialProgressGuide' 94 + key: string 95 + } 96 + | { 97 + type: 'interstitialTrending' 91 98 key: string 92 99 } 93 100 ··· 156 163 const checkForNewRef = React.useRef<(() => void) | null>(null) 157 164 const lastFetchRef = React.useRef<number>(Date.now()) 158 165 const [feedType, feedUri, feedTab] = feed.split('|') 166 + const {gtTablet} = useBreakpoints() 159 167 160 168 const opts = React.useMemo( 161 169 () => ({enabled, ignoreFilterFor}), ··· 259 267 const showProgressIntersitial = 260 268 (followProgressGuide || followAndLikeProgressGuide) && !isDesktop 261 269 270 + const {trendingDisabled} = useTrendingSettings() 271 + 262 272 const feedItems: FeedRow[] = React.useMemo(() => { 263 273 let feedKind: 'following' | 'discover' | 'profile' | undefined 264 274 if (feedType === 'following') { ··· 304 314 type: 'interstitialProgressGuide', 305 315 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 306 316 }) 307 - } else if (sliceIndex === 20) { 317 + } else if ( 318 + sliceIndex === 15 && 319 + !gtTablet && 320 + !trendingDisabled 321 + ) { 322 + arr.push({ 323 + type: 'interstitialTrending', 324 + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, 325 + }) 326 + } else if (sliceIndex === 30) { 308 327 arr.push({ 309 328 type: 'interstitialFollows', 310 329 key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, ··· 390 409 feedTab, 391 410 hasSession, 392 411 showProgressIntersitial, 412 + trendingDisabled, 413 + gtTablet, 393 414 ]) 394 415 395 416 // events ··· 476 497 return <SuggestedFollows feed={feed} /> 477 498 } else if (row.type === 'interstitialProgressGuide') { 478 499 return <ProgressGuide /> 500 + } else if (row.type === 'interstitialTrending') { 501 + return <TrendingInterstitial /> 479 502 } else if (row.type === 'sliceItem') { 480 503 const slice = row.slice 481 504 if (slice.isFallbackMarker) {
+37 -11
src/view/screens/Search/Explore.tsx
··· 24 24 ProfileCardFeedLoadingPlaceholder, 25 25 } from '#/view/com/util/LoadingPlaceholder' 26 26 import {UserAvatar} from '#/view/com/util/UserAvatar' 27 + import {ExploreRecommendations} from '#/screens/Search/components/ExploreRecommendations' 28 + import {ExploreTrendingTopics} from '#/screens/Search/components/ExploreTrendingTopics' 27 29 import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 28 30 import {Button} from '#/components/Button' 29 31 import * as FeedCard from '#/components/FeedCard' ··· 240 242 icon: React.ComponentType<SVGIconProps> 241 243 } 242 244 | { 245 + type: 'trendingTopics' 246 + key: string 247 + } 248 + | { 249 + type: 'recommendations' 250 + key: string 251 + } 252 + | { 243 253 type: 'profile' 244 254 key: string 245 255 profile: AppBskyActorDefs.ProfileView ··· 325 335 ]) 326 336 327 337 const items = React.useMemo<ExploreScreenItems[]>(() => { 328 - const i: ExploreScreenItems[] = [ 329 - { 330 - type: 'header', 331 - key: 'suggested-follows-header', 332 - title: _(msg`Suggested accounts`), 333 - description: _( 334 - msg`Follow more accounts to get connected to your interests and build your network.`, 335 - ), 336 - icon: Person, 337 - }, 338 - ] 338 + const i: ExploreScreenItems[] = [] 339 + 340 + i.push({ 341 + type: 'trendingTopics', 342 + key: `trending-topics`, 343 + }) 344 + 345 + i.push({ 346 + type: 'recommendations', 347 + key: `recommendations`, 348 + }) 349 + 350 + i.push({ 351 + type: 'header', 352 + key: 'suggested-follows-header', 353 + title: _(msg`Suggested accounts`), 354 + description: _( 355 + msg`Follow more accounts to get connected to your interests and build your network.`, 356 + ), 357 + icon: Person, 358 + }) 339 359 340 360 if (profiles) { 341 361 // Currently the responses contain duplicate items. ··· 489 509 icon={item.icon} 490 510 /> 491 511 ) 512 + } 513 + case 'trendingTopics': { 514 + return <ExploreTrendingTopics /> 515 + } 516 + case 'recommendations': { 517 + return <ExploreRecommendations /> 492 518 } 493 519 case 'profile': { 494 520 return (
+29 -3
src/view/shell/desktop/Feeds.tsx
··· 14 14 export function DesktopFeeds() { 15 15 const t = useTheme() 16 16 const {_} = useLingui() 17 - const {data: pinnedFeedInfos} = usePinnedFeedsInfos() 17 + const {data: pinnedFeedInfos, error, isLoading} = usePinnedFeedsInfos() 18 18 const selectedFeed = useSelectedFeed() 19 19 const setSelectedFeed = useSetSelectedFeed() 20 20 const navigation = useNavigation<NavigationProp>() ··· 25 25 return getCurrentRoute(state) 26 26 }) 27 27 28 - if (!pinnedFeedInfos) { 28 + if (isLoading) { 29 + return ( 30 + <View 31 + style={[ 32 + { 33 + gap: 12, 34 + }, 35 + ]}> 36 + {Array(5) 37 + .fill(0) 38 + .map((_, i) => ( 39 + <View 40 + key={i} 41 + style={[ 42 + a.rounded_sm, 43 + t.atoms.bg_contrast_25, 44 + { 45 + height: 16, 46 + width: i % 2 === 0 ? '60%' : '80%', 47 + }, 48 + ]} 49 + /> 50 + ))} 51 + </View> 52 + ) 53 + } 54 + 55 + if (error || !pinnedFeedInfos) { 29 56 return null 30 57 } 31 58 32 59 return ( 33 60 <View 34 61 style={[ 35 - a.flex_1, 36 62 web({ 37 63 gap: 10, 38 64 /*
+34 -10
src/view/shell/desktop/RightNav.tsx
··· 1 + import React from 'react' 1 2 import {View} from 'react-native' 2 3 import {msg, Trans} from '@lingui/macro' 3 4 import {useLingui} from '@lingui/react' 5 + import {useNavigation} from '@react-navigation/core' 4 6 5 7 import {FEEDBACK_FORM_URL, HELP_DESK_URL} from '#/lib/constants' 6 8 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' ··· 8 10 import {useSession} from '#/state/session' 9 11 import {DesktopFeeds} from '#/view/shell/desktop/Feeds' 10 12 import {DesktopSearch} from '#/view/shell/desktop/Search' 13 + import {SidebarTrendingTopics} from '#/view/shell/desktop/SidebarTrendingTopics' 11 14 import {atoms as a, useGutters, useTheme, web} from '#/alf' 15 + import {Divider} from '#/components/Divider' 12 16 import {InlineLinkText} from '#/components/Link' 13 17 import {ProgressGuideList} from '#/components/ProgressGuide/List' 14 18 import {Text} from '#/components/Typography' 19 + 20 + function useWebQueryParams() { 21 + const navigation = useNavigation() 22 + const [params, setParams] = React.useState<Record<string, string>>({}) 23 + 24 + React.useEffect(() => { 25 + return navigation.addListener('state', e => { 26 + try { 27 + const {state} = e.data 28 + const lastRoute = state.routes[state.routes.length - 1] 29 + const {params} = lastRoute 30 + setParams(params) 31 + } catch (e) {} 32 + }) 33 + }, [navigation, setParams]) 34 + 35 + return params 36 + } 15 37 16 38 export function DesktopRightNav({routeName}: {routeName: string}) { 17 39 const t = useTheme() ··· 19 41 const {hasSession, currentAccount} = useSession() 20 42 const kawaii = useKawaiiMode() 21 43 const gutters = useGutters(['base', 0, 'base', 'wide']) 44 + const isSearchScreen = routeName === 'Search' 45 + const webqueryParams = useWebQueryParams() 46 + const searchQuery = webqueryParams?.q 47 + const showTrending = !isSearchScreen || (isSearchScreen && !!searchQuery) 22 48 23 49 const {isTablet} = useWebMediaQueries() 24 50 if (isTablet) { ··· 29 55 <View 30 56 style={[ 31 57 gutters, 58 + a.gap_lg, 32 59 web({ 33 60 position: 'fixed', 34 61 left: '50%', ··· 43 70 overflowY: 'auto', 44 71 }), 45 72 ]}> 46 - {routeName !== 'Search' && ( 47 - <View style={[a.pb_lg]}> 48 - <DesktopSearch /> 49 - </View> 50 - )} 73 + {!isSearchScreen && <DesktopSearch />} 74 + 51 75 {hasSession && ( 52 76 <> 53 - <ProgressGuideList style={[a.pb_xl]} /> 54 - <View 55 - style={[a.pb_lg, a.mb_lg, a.border_b, t.atoms.border_contrast_low]}> 56 - <DesktopFeeds /> 57 - </View> 77 + <ProgressGuideList /> 78 + <DesktopFeeds /> 79 + <Divider /> 58 80 </> 59 81 )} 82 + 83 + {showTrending && <SidebarTrendingTopics />} 60 84 61 85 <Text style={[a.leading_snug, t.atoms.text_contrast_low]}> 62 86 {hasSession && (
+104
src/view/shell/desktop/SidebarTrendingTopics.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import { 6 + useTrendingSettings, 7 + useTrendingSettingsApi, 8 + } from '#/state/preferences/trending' 9 + import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' 10 + import {useTrendingConfig} from '#/state/trending-config' 11 + import {atoms as a, useTheme} from '#/alf' 12 + import {Button, ButtonIcon} from '#/components/Button' 13 + import {Divider} from '#/components/Divider' 14 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 15 + import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' 16 + import * as Prompt from '#/components/Prompt' 17 + import { 18 + TrendingTopic, 19 + TrendingTopicLink, 20 + TrendingTopicSkeleton, 21 + } from '#/components/TrendingTopics' 22 + import {Text} from '#/components/Typography' 23 + 24 + const TRENDING_LIMIT = 6 25 + 26 + export function SidebarTrendingTopics() { 27 + const {enabled} = useTrendingConfig() 28 + const {trendingDisabled} = useTrendingSettings() 29 + return !enabled ? null : trendingDisabled ? null : <Inner /> 30 + } 31 + 32 + function Inner() { 33 + const t = useTheme() 34 + const {_} = useLingui() 35 + const trendingPrompt = Prompt.usePromptControl() 36 + const {setTrendingDisabled} = useTrendingSettingsApi() 37 + const {data: trending, error, isLoading} = useTrendingTopics() 38 + const noTopics = !isLoading && !error && !trending?.topics?.length 39 + 40 + return error || noTopics ? null : ( 41 + <> 42 + <View style={[a.gap_sm, {paddingBottom: 2}]}> 43 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 44 + <Graph size="sm" /> 45 + <Text 46 + style={[ 47 + a.flex_1, 48 + a.text_sm, 49 + a.font_bold, 50 + t.atoms.text_contrast_medium, 51 + ]}> 52 + <Trans>Trending</Trans> 53 + </Text> 54 + <Button 55 + label={_(msg`Hide trending topics`)} 56 + size="tiny" 57 + variant="ghost" 58 + color="secondary" 59 + shape="round" 60 + onPress={() => trendingPrompt.open()}> 61 + <ButtonIcon icon={X} /> 62 + </Button> 63 + </View> 64 + 65 + <View style={[a.flex_row, a.flex_wrap, {gap: '6px 4px'}]}> 66 + {isLoading ? ( 67 + Array(TRENDING_LIMIT) 68 + .fill(0) 69 + .map((_n, i) => ( 70 + <TrendingTopicSkeleton key={i} size="small" index={i} /> 71 + )) 72 + ) : !trending?.topics ? null : ( 73 + <> 74 + {trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( 75 + <TrendingTopicLink key={topic.link} topic={topic}> 76 + {({hovered}) => ( 77 + <TrendingTopic 78 + size="small" 79 + topic={topic} 80 + style={[ 81 + hovered && [ 82 + t.atoms.border_contrast_high, 83 + t.atoms.bg_contrast_25, 84 + ], 85 + ]} 86 + /> 87 + )} 88 + </TrendingTopicLink> 89 + ))} 90 + </> 91 + )} 92 + </View> 93 + </View> 94 + <Prompt.Basic 95 + control={trendingPrompt} 96 + title={_(msg`Hide trending topics?`)} 97 + description={_(msg`You can update this later from your settings.`)} 98 + confirmButtonCta={_(msg`Hide`)} 99 + onConfirm={() => setTrendingDisabled(true)} 100 + /> 101 + <Divider /> 102 + </> 103 + ) 104 + }
+18 -42
yarn.lock
··· 72 72 tlds "^1.234.0" 73 73 zod "^3.23.8" 74 74 75 + "@atproto/api@^0.13.21": 76 + version "0.13.21" 77 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc" 78 + integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA== 79 + dependencies: 80 + "@atproto/common-web" "^0.3.1" 81 + "@atproto/lexicon" "^0.4.4" 82 + "@atproto/syntax" "^0.3.1" 83 + "@atproto/xrpc" "^0.6.5" 84 + await-lock "^2.2.2" 85 + multiformats "^9.9.0" 86 + tlds "^1.234.0" 87 + zod "^3.23.8" 88 + 75 89 "@atproto/aws@^0.2.10": 76 90 version "0.2.10" 77 91 resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4" ··· 3247 3261 "@babel/parser" "^7.25.9" 3248 3262 "@babel/types" "^7.25.9" 3249 3263 3250 - "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3": 3264 + "@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": 3251 3265 version "7.25.9" 3252 3266 resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" 3253 3267 integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== ··· 3305 3319 "@babel/helper-split-export-declaration" "^7.24.5" 3306 3320 "@babel/parser" "^7.24.5" 3307 3321 "@babel/types" "^7.24.5" 3308 - debug "^4.3.1" 3309 - globals "^11.1.0" 3310 - 3311 - "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9": 3312 - version "7.25.9" 3313 - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" 3314 - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== 3315 - dependencies: 3316 - "@babel/code-frame" "^7.25.9" 3317 - "@babel/generator" "^7.25.9" 3318 - "@babel/parser" "^7.25.9" 3319 - "@babel/template" "^7.25.9" 3320 - "@babel/types" "^7.25.9" 3321 3322 debug "^4.3.1" 3322 3323 globals "^11.1.0" 3323 3324 ··· 17456 17457 resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" 17457 17458 integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== 17458 17459 17459 - "string-width-cjs@npm:string-width@^4.2.0": 17460 - version "4.2.3" 17461 - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 17462 - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 17463 - dependencies: 17464 - emoji-regex "^8.0.0" 17465 - is-fullwidth-code-point "^3.0.0" 17466 - strip-ansi "^6.0.1" 17467 - 17468 - string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 17460 + "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: 17469 17461 version "4.2.3" 17470 17462 resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 17471 17463 integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== ··· 17565 17557 dependencies: 17566 17558 safe-buffer "~5.1.0" 17567 17559 17568 - "strip-ansi-cjs@npm:strip-ansi@^6.0.1": 17560 + "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: 17569 17561 version "6.0.1" 17570 17562 resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 17571 17563 integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== ··· 17578 17570 integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== 17579 17571 dependencies: 17580 17572 ansi-regex "^4.1.0" 17581 - 17582 - strip-ansi@^6.0.0, strip-ansi@^6.0.1: 17583 - version "6.0.1" 17584 - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 17585 - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 17586 - dependencies: 17587 - ansi-regex "^5.0.1" 17588 17573 17589 17574 strip-ansi@^7.0.1: 17590 17575 version "7.1.0" ··· 18860 18845 resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" 18861 18846 integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== 18862 18847 18863 - "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": 18848 + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: 18864 18849 version "7.0.0" 18865 18850 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 18866 18851 integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== ··· 18873 18858 version "6.2.0" 18874 18859 resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" 18875 18860 integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== 18876 - dependencies: 18877 - ansi-styles "^4.0.0" 18878 - string-width "^4.1.0" 18879 - strip-ansi "^6.0.0" 18880 - 18881 - wrap-ansi@^7.0.0: 18882 - version "7.0.0" 18883 - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 18884 - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 18885 18861 dependencies: 18886 18862 ansi-styles "^4.0.0" 18887 18863 string-width "^4.1.0"