about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Search/components/ExploreRecommendations.tsx95
-rw-r--r--src/screens/Search/components/ExploreTrendingTopics.tsx102
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx27
-rw-r--r--src/screens/Topic.tsx204
4 files changed, 428 insertions, 0 deletions
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}
+        />
+      )}
+    </>
+  )
+}