Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Content interests (#8114)

* Content prefs screen

* Handle debounce, rename

* Fix format

* Let's just use interests

* Reuse hook

authored by

Eric Bailey and committed by
GitHub
148bfa80 0004e274

+250 -3
+1
bskyweb/cmd/bskyweb/server.go
··· 275 275 e.GET("/settings/account", server.WebGeneric) 276 276 e.GET("/settings/privacy-and-security", server.WebGeneric) 277 277 e.GET("/settings/content-and-media", server.WebGeneric) 278 + e.GET("/settings/interests", server.WebGeneric) 278 279 e.GET("/settings/about", server.WebGeneric) 279 280 e.GET("/settings/app-icon", server.WebGeneric) 280 281 e.GET("/sys/debug", server.WebGeneric)
+9
src/Navigation.tsx
··· 83 83 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 84 84 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' 85 85 import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' 86 + import {SettingsInterests} from '#/screens/Settings/SettingsInterests' 86 87 import { 87 88 StarterPackScreen, 88 89 StarterPackScreenShort, ··· 372 373 getComponent={() => ContentAndMediaSettingsScreen} 373 374 options={{ 374 375 title: title(msg`Content and Media`), 376 + requireAuth: true, 377 + }} 378 + /> 379 + <Stack.Screen 380 + name="SettingsInterests" 381 + getComponent={() => SettingsInterests} 382 + options={{ 383 + title: title(msg`Your interests`), 375 384 requireAuth: true, 376 385 }} 377 386 />
+4 -3
src/lib/routes/types.ts
··· 1 - import {NavigationState, PartialState} from '@react-navigation/native' 2 - import type {NativeStackNavigationProp} from '@react-navigation/native-stack' 1 + import {type NavigationState, type PartialState} from '@react-navigation/native' 2 + import {type NativeStackNavigationProp} from '@react-navigation/native-stack' 3 3 4 - import {VideoFeedSourceContext} from '#/screens/VideoFeed/types' 4 + import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' 5 5 6 6 export type {NativeStackScreenProps} from '@react-navigation/native-stack' 7 7 ··· 51 51 AccountSettings: undefined 52 52 PrivacyAndSecuritySettings: undefined 53 53 ContentAndMediaSettings: undefined 54 + SettingsInterests: undefined 54 55 AboutSettings: undefined 55 56 AppIconSettings: undefined 56 57 Search: {q?: string}
+1
src/routes.ts
··· 45 45 AccountSettings: '/settings/account', 46 46 PrivacyAndSecuritySettings: '/settings/privacy-and-security', 47 47 ContentAndMediaSettings: '/settings/content-and-media', 48 + SettingsInterests: '/settings/interests', 48 49 AboutSettings: '/settings/about', 49 50 AppIconSettings: '/settings/app-icon', 50 51 // support
+9
src/screens/Settings/ContentAndMediaSettings.tsx
··· 18 18 import * as SettingsList from '#/screens/Settings/components/SettingsList' 19 19 import * as Toggle from '#/components/forms/Toggle' 20 20 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 21 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 21 22 import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons/Hashtag' 22 23 import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' 23 24 import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' ··· 84 85 <SettingsList.ItemIcon icon={MacintoshIcon} /> 85 86 <SettingsList.ItemText> 86 87 <Trans>External media</Trans> 88 + </SettingsList.ItemText> 89 + </SettingsList.LinkItem> 90 + <SettingsList.LinkItem 91 + to="/settings/interests" 92 + label={_(msg`Your interests`)}> 93 + <SettingsList.ItemIcon icon={CircleInfo} /> 94 + <SettingsList.ItemText> 95 + <Trans>Your interests</Trans> 87 96 </SettingsList.ItemText> 88 97 </SettingsList.LinkItem> 89 98 <SettingsList.Divider />
+226
src/screens/Settings/SettingsInterests.tsx
··· 1 + import {useMemo, useState} from 'react' 2 + import {type TextStyle, View, type ViewStyle} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useQueryClient} from '@tanstack/react-query' 6 + import debounce from 'lodash.debounce' 7 + 8 + import { 9 + preferencesQueryKey, 10 + usePreferencesQuery, 11 + } from '#/state/queries/preferences' 12 + import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types' 13 + import {useAgent} from '#/state/session' 14 + import * as Toast from '#/view/com/util/Toast' 15 + import {useInterestsDisplayNames} from '#/screens/Onboarding/state' 16 + import {atoms as a, useGutters, useTheme} from '#/alf' 17 + import {Divider} from '#/components/Divider' 18 + import * as Toggle from '#/components/forms/Toggle' 19 + import * as Layout from '#/components/Layout' 20 + import {Loader} from '#/components/Loader' 21 + import {Text} from '#/components/Typography' 22 + 23 + export function SettingsInterests() { 24 + const t = useTheme() 25 + const gutters = useGutters(['base']) 26 + const {data: preferences} = usePreferencesQuery() 27 + const [isSaving, setIsSaving] = useState(false) 28 + 29 + return ( 30 + <Layout.Screen> 31 + <Layout.Header.Outer> 32 + <Layout.Header.BackButton /> 33 + <Layout.Header.Content> 34 + <Layout.Header.TitleText> 35 + <Trans>Your interests</Trans> 36 + </Layout.Header.TitleText> 37 + </Layout.Header.Content> 38 + <Layout.Header.Slot>{isSaving && <Loader />}</Layout.Header.Slot> 39 + </Layout.Header.Outer> 40 + <Layout.Content> 41 + <View style={[gutters, a.gap_lg]}> 42 + <Text 43 + style={[ 44 + a.flex_1, 45 + a.text_sm, 46 + a.leading_snug, 47 + t.atoms.text_contrast_medium, 48 + ]}> 49 + <Trans> 50 + Selecting interests from the list below helps us deliver you 51 + higher quality content. 52 + </Trans> 53 + </Text> 54 + 55 + <Divider /> 56 + 57 + {preferences ? ( 58 + <Inner preferences={preferences} setIsSaving={setIsSaving} /> 59 + ) : ( 60 + <View style={[a.flex_row, a.justify_center, a.p_lg]}> 61 + <Loader size="xl" /> 62 + </View> 63 + )} 64 + </View> 65 + </Layout.Content> 66 + </Layout.Screen> 67 + ) 68 + } 69 + 70 + function Inner({ 71 + preferences, 72 + setIsSaving, 73 + }: { 74 + preferences: UsePreferencesQueryResponse 75 + setIsSaving: (isSaving: boolean) => void 76 + }) { 77 + const {_} = useLingui() 78 + const agent = useAgent() 79 + const qc = useQueryClient() 80 + const interestsDisplayNames = useInterestsDisplayNames() 81 + const preselectedInterests = useMemo( 82 + () => preferences.interests.tags || [], 83 + [preferences.interests.tags], 84 + ) 85 + const [interests, setInterests] = useState<string[]>(preselectedInterests) 86 + 87 + const saveInterests = useMemo(() => { 88 + return debounce(async (interests: string[]) => { 89 + const noEdits = 90 + interests.length === preselectedInterests.length && 91 + preselectedInterests.every(pre => { 92 + return interests.find(int => int === pre) 93 + }) 94 + 95 + if (noEdits) return 96 + 97 + setIsSaving(true) 98 + 99 + try { 100 + await agent.setInterestsPref({tags: interests}) 101 + await qc.invalidateQueries({queryKey: preferencesQueryKey}) 102 + Toast.show( 103 + _(msg({message: 'Content preferences updated!', context: 'toast'})), 104 + ) 105 + } catch (error) { 106 + Toast.show( 107 + _( 108 + msg({ 109 + message: 'Failed to save content prefefences.', 110 + context: 'toast', 111 + }), 112 + ), 113 + 'xmark', 114 + ) 115 + } finally { 116 + setIsSaving(false) 117 + } 118 + }, 1500) 119 + }, [_, agent, setIsSaving, qc, preselectedInterests]) 120 + 121 + const onChangeInterests = async (interests: string[]) => { 122 + setInterests(interests) 123 + saveInterests(interests) 124 + } 125 + 126 + return ( 127 + <Toggle.Group 128 + values={interests} 129 + onChange={onChangeInterests} 130 + label={_(msg`Select your interests from the options below`)}> 131 + <View style={[a.flex_row, a.flex_wrap, a.gap_sm]}> 132 + {INTERESTS.map(interest => { 133 + const name = interestsDisplayNames[interest] 134 + if (!name) return null 135 + return ( 136 + <Toggle.Item 137 + key={interest} 138 + name={interest} 139 + label={interestsDisplayNames[interest]}> 140 + <InterestButton interest={interest} /> 141 + </Toggle.Item> 142 + ) 143 + })} 144 + </View> 145 + </Toggle.Group> 146 + ) 147 + } 148 + 149 + export function InterestButton({interest}: {interest: string}) { 150 + const t = useTheme() 151 + const interestsDisplayNames = useInterestsDisplayNames() 152 + const ctx = Toggle.useItemContext() 153 + 154 + const styles = useMemo(() => { 155 + const hovered: ViewStyle[] = [t.atoms.bg_contrast_100] 156 + const focused: ViewStyle[] = [] 157 + const pressed: ViewStyle[] = [] 158 + const selected: ViewStyle[] = [t.atoms.bg_contrast_900] 159 + const selectedHover: ViewStyle[] = [t.atoms.bg_contrast_975] 160 + const textSelected: TextStyle[] = [t.atoms.text_inverted] 161 + 162 + return { 163 + hovered, 164 + focused, 165 + pressed, 166 + selected, 167 + selectedHover, 168 + textSelected, 169 + } 170 + }, [t]) 171 + 172 + return ( 173 + <View 174 + style={[ 175 + a.rounded_full, 176 + a.py_md, 177 + a.px_xl, 178 + t.atoms.bg_contrast_50, 179 + ctx.hovered ? styles.hovered : {}, 180 + ctx.focused ? styles.hovered : {}, 181 + ctx.pressed ? styles.hovered : {}, 182 + ctx.selected ? styles.selected : {}, 183 + ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed) 184 + ? styles.selectedHover 185 + : {}, 186 + ]}> 187 + <Text 188 + selectable={false} 189 + style={[ 190 + { 191 + color: t.palette.contrast_900, 192 + }, 193 + a.font_bold, 194 + ctx.selected ? styles.textSelected : {}, 195 + ]}> 196 + {interestsDisplayNames[interest]} 197 + </Text> 198 + </View> 199 + ) 200 + } 201 + 202 + const INTERESTS = [ 203 + 'animals', 204 + 'art', 205 + 'books', 206 + 'comedy', 207 + 'comics', 208 + 'culture', 209 + 'dev', 210 + 'education', 211 + 'food', 212 + 'gaming', 213 + 'journalism', 214 + 'movies', 215 + 'music', 216 + 'nature', 217 + 'news', 218 + 'pets', 219 + 'photography', 220 + 'politics', 221 + 'science', 222 + 'sports', 223 + 'tech', 224 + 'tv', 225 + 'writers', 226 + ]