about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--src/Navigation.tsx9
-rw-r--r--src/lib/routes/types.ts7
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Settings/ContentAndMediaSettings.tsx9
-rw-r--r--src/screens/Settings/SettingsInterests.tsx226
6 files changed, 250 insertions, 3 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 73fa71019..b74df5ff1 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -275,6 +275,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/settings/account", server.WebGeneric)
 	e.GET("/settings/privacy-and-security", server.WebGeneric)
 	e.GET("/settings/content-and-media", server.WebGeneric)
+	e.GET("/settings/interests", server.WebGeneric)
 	e.GET("/settings/about", server.WebGeneric)
 	e.GET("/settings/app-icon", server.WebGeneric)
 	e.GET("/sys/debug", server.WebGeneric)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 420a49d4c..d89d45919 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -83,6 +83,7 @@ import {SearchScreen} from '#/screens/Search'
 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
 import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
+import {SettingsInterests} from '#/screens/Settings/SettingsInterests'
 import {
   StarterPackScreen,
   StarterPackScreenShort,
@@ -376,6 +377,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         }}
       />
       <Stack.Screen
+        name="SettingsInterests"
+        getComponent={() => SettingsInterests}
+        options={{
+          title: title(msg`Your interests`),
+          requireAuth: true,
+        }}
+      />
+      <Stack.Screen
         name="AboutSettings"
         getComponent={() => AboutSettingsScreen}
         options={{
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0e38c9262..658b68db8 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -1,7 +1,7 @@
-import {NavigationState, PartialState} from '@react-navigation/native'
-import type {NativeStackNavigationProp} from '@react-navigation/native-stack'
+import {type NavigationState, type PartialState} from '@react-navigation/native'
+import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
 
-import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
+import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
 
 export type {NativeStackScreenProps} from '@react-navigation/native-stack'
 
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
   AccountSettings: undefined
   PrivacyAndSecuritySettings: undefined
   ContentAndMediaSettings: undefined
+  SettingsInterests: undefined
   AboutSettings: undefined
   AppIconSettings: undefined
   Search: {q?: string}
diff --git a/src/routes.ts b/src/routes.ts
index b6a11acbf..68c39e7fc 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -45,6 +45,7 @@ export const router = new Router({
   AccountSettings: '/settings/account',
   PrivacyAndSecuritySettings: '/settings/privacy-and-security',
   ContentAndMediaSettings: '/settings/content-and-media',
+  SettingsInterests: '/settings/interests',
   AboutSettings: '/settings/about',
   AppIconSettings: '/settings/app-icon',
   // support
diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx
index 57b86fb2b..6fa90b1e2 100644
--- a/src/screens/Settings/ContentAndMediaSettings.tsx
+++ b/src/screens/Settings/ContentAndMediaSettings.tsx
@@ -18,6 +18,7 @@ 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'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons/Hashtag'
 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home'
 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh'
@@ -86,6 +87,14 @@ export function ContentAndMediaSettingsScreen({}: Props) {
               <Trans>External media</Trans>
             </SettingsList.ItemText>
           </SettingsList.LinkItem>
+          <SettingsList.LinkItem
+            to="/settings/interests"
+            label={_(msg`Your interests`)}>
+            <SettingsList.ItemIcon icon={CircleInfo} />
+            <SettingsList.ItemText>
+              <Trans>Your interests</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
           <SettingsList.Divider />
           {isNative && (
             <Toggle.Item
diff --git a/src/screens/Settings/SettingsInterests.tsx b/src/screens/Settings/SettingsInterests.tsx
new file mode 100644
index 000000000..266545560
--- /dev/null
+++ b/src/screens/Settings/SettingsInterests.tsx
@@ -0,0 +1,226 @@
+import {useMemo, useState} from 'react'
+import {type TextStyle, View, type ViewStyle} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import debounce from 'lodash.debounce'
+
+import {
+  preferencesQueryKey,
+  usePreferencesQuery,
+} from '#/state/queries/preferences'
+import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
+import {useAgent} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
+import {atoms as a, useGutters, useTheme} from '#/alf'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import * as Layout from '#/components/Layout'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function SettingsInterests() {
+  const t = useTheme()
+  const gutters = useGutters(['base'])
+  const {data: preferences} = usePreferencesQuery()
+  const [isSaving, setIsSaving] = useState(false)
+
+  return (
+    <Layout.Screen>
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content>
+          <Layout.Header.TitleText>
+            <Trans>Your interests</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        <Layout.Header.Slot>{isSaving && <Loader />}</Layout.Header.Slot>
+      </Layout.Header.Outer>
+      <Layout.Content>
+        <View style={[gutters, a.gap_lg]}>
+          <Text
+            style={[
+              a.flex_1,
+              a.text_sm,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>
+              Selecting interests from the list below helps us deliver you
+              higher quality content.
+            </Trans>
+          </Text>
+
+          <Divider />
+
+          {preferences ? (
+            <Inner preferences={preferences} setIsSaving={setIsSaving} />
+          ) : (
+            <View style={[a.flex_row, a.justify_center, a.p_lg]}>
+              <Loader size="xl" />
+            </View>
+          )}
+        </View>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
+
+function Inner({
+  preferences,
+  setIsSaving,
+}: {
+  preferences: UsePreferencesQueryResponse
+  setIsSaving: (isSaving: boolean) => void
+}) {
+  const {_} = useLingui()
+  const agent = useAgent()
+  const qc = useQueryClient()
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const preselectedInterests = useMemo(
+    () => preferences.interests.tags || [],
+    [preferences.interests.tags],
+  )
+  const [interests, setInterests] = useState<string[]>(preselectedInterests)
+
+  const saveInterests = useMemo(() => {
+    return debounce(async (interests: string[]) => {
+      const noEdits =
+        interests.length === preselectedInterests.length &&
+        preselectedInterests.every(pre => {
+          return interests.find(int => int === pre)
+        })
+
+      if (noEdits) return
+
+      setIsSaving(true)
+
+      try {
+        await agent.setInterestsPref({tags: interests})
+        await qc.invalidateQueries({queryKey: preferencesQueryKey})
+        Toast.show(
+          _(msg({message: 'Content preferences updated!', context: 'toast'})),
+        )
+      } catch (error) {
+        Toast.show(
+          _(
+            msg({
+              message: 'Failed to save content prefefences.',
+              context: 'toast',
+            }),
+          ),
+          'xmark',
+        )
+      } finally {
+        setIsSaving(false)
+      }
+    }, 1500)
+  }, [_, agent, setIsSaving, qc, preselectedInterests])
+
+  const onChangeInterests = async (interests: string[]) => {
+    setInterests(interests)
+    saveInterests(interests)
+  }
+
+  return (
+    <Toggle.Group
+      values={interests}
+      onChange={onChangeInterests}
+      label={_(msg`Select your interests from the options below`)}>
+      <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
+        {INTERESTS.map(interest => {
+          const name = interestsDisplayNames[interest]
+          if (!name) return null
+          return (
+            <Toggle.Item
+              key={interest}
+              name={interest}
+              label={interestsDisplayNames[interest]}>
+              <InterestButton interest={interest} />
+            </Toggle.Item>
+          )
+        })}
+      </View>
+    </Toggle.Group>
+  )
+}
+
+export function InterestButton({interest}: {interest: string}) {
+  const t = useTheme()
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const ctx = Toggle.useItemContext()
+
+  const styles = useMemo(() => {
+    const hovered: ViewStyle[] = [t.atoms.bg_contrast_100]
+    const focused: ViewStyle[] = []
+    const pressed: ViewStyle[] = []
+    const selected: ViewStyle[] = [t.atoms.bg_contrast_900]
+    const selectedHover: ViewStyle[] = [t.atoms.bg_contrast_975]
+    const textSelected: TextStyle[] = [t.atoms.text_inverted]
+
+    return {
+      hovered,
+      focused,
+      pressed,
+      selected,
+      selectedHover,
+      textSelected,
+    }
+  }, [t])
+
+  return (
+    <View
+      style={[
+        a.rounded_full,
+        a.py_md,
+        a.px_xl,
+        t.atoms.bg_contrast_50,
+        ctx.hovered ? styles.hovered : {},
+        ctx.focused ? styles.hovered : {},
+        ctx.pressed ? styles.hovered : {},
+        ctx.selected ? styles.selected : {},
+        ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed)
+          ? styles.selectedHover
+          : {},
+      ]}>
+      <Text
+        selectable={false}
+        style={[
+          {
+            color: t.palette.contrast_900,
+          },
+          a.font_bold,
+          ctx.selected ? styles.textSelected : {},
+        ]}>
+        {interestsDisplayNames[interest]}
+      </Text>
+    </View>
+  )
+}
+
+const INTERESTS = [
+  'animals',
+  'art',
+  'books',
+  'comedy',
+  'comics',
+  'culture',
+  'dev',
+  'education',
+  'food',
+  'gaming',
+  'journalism',
+  'movies',
+  'music',
+  'nature',
+  'news',
+  'pets',
+  'photography',
+  'politics',
+  'science',
+  'sports',
+  'tech',
+  'tv',
+  'writers',
+]