about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2024-12-17 21:45:39 -0600
committerGitHub <noreply@github.com>2024-12-17 19:45:39 -0800
commita2019aceec001e276272832b97ea5e2ec864c8a5 (patch)
treeeaddab8a7a009650d93bb3b49c750619d98bb44d /src/state
parenta07949ec8e63bae178a829f65c33fcd9622b28ec (diff)
downloadvoidsky-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/state')
-rw-r--r--src/state/persisted/schema.ts2
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/state/preferences/trending.tsx69
-rw-r--r--src/state/queries/index.ts1
-rw-r--r--src/state/queries/service-config.ts32
-rw-r--r--src/state/queries/trending/useTrendingTopics.ts49
-rw-r--r--src/state/trending-config.tsx70
7 files changed, 227 insertions, 1 deletions
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)
+}