diff options
-rw-r--r-- | bskyweb/cmd/bskyweb/server.go | 1 | ||||
-rw-r--r-- | src/Navigation.tsx | 9 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 7 | ||||
-rw-r--r-- | src/routes.ts | 1 | ||||
-rw-r--r-- | src/screens/Settings/ContentAndMediaSettings.tsx | 9 | ||||
-rw-r--r-- | src/screens/Settings/SettingsInterests.tsx | 226 |
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', +] |