Trending (Beta) (#7144)
* Add WIP UIs for trending topics and suggested starterpacks
* Disable SPs for now
* Improve explore treatment a bit, add some polish to cards
* Add tiny option in RightNav
* Add persisted option to hide trending from sidebar
* Add to settings, abstract state, not updating in tab
* Fix up hide/show toggle state, WITH broadcast hacK
* Clean up persisted code, add new setting
* Add new interstitial to Discover
* Exploration
* First hack at mute words
* Wire up interstitial and Explore page
* Align components
* Some skeleton UI
* Handle service config, enablement, load states, update lex contract
* Centralize mute word handling
* Stale time to 30m
* Cache enabled value for reloads, use real data for service config
* Remove broadcast hack
* Remove titleChild
* Gate settings too
* Update package, rm langs
* Add feature gate
* Only english during beta period
* Hook up real data
* Tweak config
* Straight passthrough links
* Hook up prod agent
* Fix no-show logic
* Up config query to 5 min
* Remove old file
* Remove comment
* Remove stray flex_1
* Make trending setting global
* Quick placeholder state
* Limit # in sidebar, tweak spacing
* Tweak gaps
* Handle hide/show of sidebar
* Simplify messages
* Remove interstitial
* Revert "Remove interstitial"
This reverts commit 1358ad47fd.
* Only show interstitial on mobile
* Fix gap
* Add explore page recommendations
* [topics] add topic screen (#7149)
* add topic screen
* decode
* fix search query
* decode
* add server route
* Fix potential bad destructure (undefined)
---------
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
@@ -125,6 +125,7 @@ const schema = z.object({
|
||||
subtitlesEnabled: z.boolean().optional(),
|
||||
/** @deprecated */
|
||||
mutedThreads: z.array(z.string()),
|
||||
trendingDisabled: z.boolean().optional(),
|
||||
})
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -170,6 +171,7 @@ export const defaults: Schema = {
|
||||
kawaii: false,
|
||||
hasCheckedForStarterPack: false,
|
||||
subtitlesEnabled: true,
|
||||
trendingDisabled: false,
|
||||
}
|
||||
|
||||
export function tryParse(rawData: string): Schema | undefined {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Provider as KawaiiProvider} from './kawaii'
|
||||
import {Provider as LanguagesProvider} from './languages'
|
||||
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
|
||||
import {Provider as SubtitlesProvider} from './subtitles'
|
||||
import {Provider as TrendingSettingsProvider} from './trending'
|
||||
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
|
||||
|
||||
export {
|
||||
@@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
<AutoplayProvider>
|
||||
<UsedStarterPacksProvider>
|
||||
<SubtitlesProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
<TrendingSettingsProvider>
|
||||
<KawaiiProvider>{children}</KawaiiProvider>
|
||||
</TrendingSettingsProvider>
|
||||
</SubtitlesProvider>
|
||||
</UsedStarterPacksProvider>
|
||||
</AutoplayProvider>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as persisted from '#/state/persisted'
|
||||
|
||||
type StateContext = {
|
||||
trendingDisabled: Exclude<persisted.Schema['trendingDisabled'], undefined>
|
||||
}
|
||||
type ApiContext = {
|
||||
setTrendingDisabled(
|
||||
hidden: Exclude<persisted.Schema['trendingDisabled'], undefined>,
|
||||
): void
|
||||
}
|
||||
|
||||
const StateContext = React.createContext<StateContext>({
|
||||
trendingDisabled: Boolean(persisted.defaults.trendingDisabled),
|
||||
})
|
||||
const ApiContext = React.createContext<ApiContext>({
|
||||
setTrendingDisabled() {},
|
||||
})
|
||||
|
||||
function usePersistedBooleanValue<T extends keyof persisted.Schema>(key: T) {
|
||||
const [value, _set] = React.useState(() => {
|
||||
return Boolean(persisted.get(key))
|
||||
})
|
||||
const set = React.useCallback<
|
||||
(value: Exclude<persisted.Schema[T], undefined>) => void
|
||||
>(
|
||||
hidden => {
|
||||
_set(Boolean(hidden))
|
||||
persisted.write(key, hidden)
|
||||
},
|
||||
[key, _set],
|
||||
)
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate(key, hidden => {
|
||||
_set(Boolean(hidden))
|
||||
})
|
||||
}, [key, _set])
|
||||
|
||||
return [value, set] as const
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const [trendingDisabled, setTrendingDisabled] =
|
||||
usePersistedBooleanValue('trendingDisabled')
|
||||
|
||||
/*
|
||||
* Context
|
||||
*/
|
||||
const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled])
|
||||
const api = React.useMemo(
|
||||
() => ({setTrendingDisabled}),
|
||||
[setTrendingDisabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<StateContext.Provider value={state}>
|
||||
<ApiContext.Provider value={api}>{children}</ApiContext.Provider>
|
||||
</StateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTrendingSettings() {
|
||||
return React.useContext(StateContext)
|
||||
}
|
||||
|
||||
export function useTrendingSettingsApi() {
|
||||
return React.useContext(ApiContext)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const STALE = {
|
||||
MINUTES: {
|
||||
ONE: 1e3 * 60,
|
||||
FIVE: 1e3 * 60 * 5,
|
||||
THIRTY: 1e3 * 60 * 30,
|
||||
},
|
||||
HOURS: {
|
||||
ONE: 1e3 * 60 * 60,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
type ServiceConfig = {
|
||||
checkEmailConfirmed: boolean
|
||||
topicsEnabled: boolean
|
||||
}
|
||||
|
||||
export function useServiceConfigQuery() {
|
||||
const agent = useAgent()
|
||||
return useQuery<ServiceConfig>({
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: STALE.MINUTES.FIVE,
|
||||
queryKey: ['service-config'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const {data} = await agent.api.app.bsky.unspecced.getConfig()
|
||||
return {
|
||||
checkEmailConfirmed: Boolean(data.checkEmailConfirmed),
|
||||
topicsEnabled: Boolean(data.topicsEnabled),
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
checkEmailConfirmed: false,
|
||||
topicsEnabled: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import {AppBskyUnspeccedDefs} from '@atproto/api'
|
||||
import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {STALE} from '#/state/queries'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic
|
||||
|
||||
export const DEFAULT_LIMIT = 14
|
||||
|
||||
export const trendingTopicsQueryKey = ['trending-topics']
|
||||
|
||||
export function useTrendingTopics() {
|
||||
const agent = useAgent()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const mutedWords = React.useMemo(() => {
|
||||
return preferences?.moderationPrefs?.mutedWords || []
|
||||
}, [preferences?.moderationPrefs])
|
||||
|
||||
return useQuery({
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: STALE.MINUTES.THIRTY,
|
||||
queryKey: trendingTopicsQueryKey,
|
||||
async queryFn() {
|
||||
const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
|
||||
const {topics, suggested} = data
|
||||
return {
|
||||
topics: topics.filter(t => {
|
||||
return !hasMutedWord({
|
||||
mutedWords,
|
||||
text: t.topic + ' ' + t.displayName + ' ' + t.description,
|
||||
})
|
||||
}),
|
||||
suggested: suggested.filter(t => {
|
||||
return !hasMutedWord({
|
||||
mutedWords,
|
||||
text: t.topic + ' ' + t.displayName + ' ' + t.description,
|
||||
})
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||
import {useServiceConfigQuery} from '#/state/queries/service-config'
|
||||
import {device} from '#/storage'
|
||||
|
||||
type Context = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const Context = React.createContext<Context>({
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const gate = useGate()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery()
|
||||
const ctx = React.useMemo<Context>(() => {
|
||||
if (__DEV__) {
|
||||
return {enabled: true}
|
||||
}
|
||||
|
||||
/*
|
||||
* Only English during beta period
|
||||
*/
|
||||
if (
|
||||
!!langPrefs.contentLanguages.length &&
|
||||
!langPrefs.contentLanguages.includes('en')
|
||||
) {
|
||||
return {enabled: false}
|
||||
}
|
||||
|
||||
/*
|
||||
* While loading, use cached value
|
||||
*/
|
||||
const cachedEnabled = device.get(['trendingBetaEnabled'])
|
||||
if (isInitialLoad) {
|
||||
return {enabled: Boolean(cachedEnabled)}
|
||||
}
|
||||
|
||||
/*
|
||||
* Doing an extra check here to reduce hits to statsig. If it's disabled on
|
||||
* the server, we can exit early.
|
||||
*/
|
||||
const enabled = Boolean(config?.topicsEnabled)
|
||||
if (!enabled) {
|
||||
// cache for next reload
|
||||
device.set(['trendingBetaEnabled'], enabled)
|
||||
return {enabled: false}
|
||||
}
|
||||
|
||||
/*
|
||||
* Service is enabled, but also check statsig in case we're rolling back.
|
||||
*/
|
||||
const gateEnabled = gate('trending_topics_beta')
|
||||
const _enabled = enabled && gateEnabled
|
||||
|
||||
// update cache
|
||||
device.set(['trendingBetaEnabled'], _enabled)
|
||||
|
||||
return {enabled: _enabled}
|
||||
}, [isInitialLoad, config, gate, langPrefs.contentLanguages])
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useTrendingConfig() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
Reference in New Issue
Block a user