An ATproto social media client -- with an independent Appview.

[APP-549] Language controls for Whats Hot (#563)

* Add a content-language preference control

* Update whats hot to only show the selected languages and to refresh on lang pref changes

* Fix lint

* Fix tests

* Add missing accessibility role

authored by Paul Frazee and committed by GitHub 6f1c4ec9 95f8360d

+1
__mocks__/expo-localization.js
··· 1 + export const getLocales = jest.fn().mockResolvedValue([])
+4 -6
src/lib/api/feed-manip.ts
··· 202 202 tuner: FeedTuner, 203 203 slices: FeedViewPostsSlice[], 204 204 ): FeedViewPostsSlice[] => { 205 - const origSlices = slices.concat() 205 + if (!langsCode2.length) { 206 + return slices 207 + } 206 208 for (let i = slices.length - 1; i >= 0; i--) { 207 209 let hasPreferredLang = false 208 210 for (const item of slices[i].items) { ··· 236 238 slices.splice(i, 1) 237 239 } 238 240 } 239 - if (slices.length) { 240 - return slices 241 - } 242 - // fallback: give everything if the language filter left nothing 243 - return origSlices 241 + return slices 244 242 } 245 243 } 246 244 }
+1 -1
src/locale/languages.ts
··· 23 23 {code3: 'alt', code2: '', name: 'Southern Altai'}, 24 24 {code3: 'amh', code2: 'am', name: 'Amharic'}, 25 25 {code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'}, 26 - {code3: 'anp ', code2: 'Angika', name: 'angika'}, 26 + {code3: 'anp ', code2: 'Angika', name: 'Angika'}, 27 27 {code3: 'apa', code2: '', name: 'Apache languages'}, 28 28 {code3: 'ara', code2: 'ar', name: 'Arabic'}, 29 29 {
+12
src/state/models/feeds/posts.ts
··· 297 297 // used to linearize async modifications to state 298 298 lock = new AwaitLock() 299 299 300 + // used to track if what's hot is coming up empty 301 + emptyFetches = 0 302 + 300 303 // data 301 304 slices: PostsFeedSliceModel[] = [] 302 305 ··· 603 606 ) { 604 607 this.loadMoreCursor = res.data.cursor 605 608 this.hasMore = !!this.loadMoreCursor 609 + if (replace) { 610 + this.emptyFetches = 0 611 + } 606 612 607 613 this.rootStore.me.follows.hydrateProfiles( 608 614 res.data.feed.map(item => item.post.author), ··· 624 630 this.slices = toAppend 625 631 } else { 626 632 this.slices = this.slices.concat(toAppend) 633 + } 634 + if (toAppend.length === 0) { 635 + this.emptyFetches++ 636 + if (this.emptyFetches >= 10) { 637 + this.hasMore = false 638 + } 627 639 } 628 640 }) 629 641 }
+23 -16
src/state/models/ui/preferences.ts
··· 2 2 import {getLocales} from 'expo-localization' 3 3 import {isObj, hasProp} from 'lib/type-guards' 4 4 import {ComAtprotoLabelDefs} from '@atproto/api' 5 + import {LabelValGroup} from 'lib/labeling/types' 5 6 import {getLabelValueGroup} from 'lib/labeling/helpers' 6 - import { 7 - LabelValGroup, 8 - UNKNOWN_LABEL_GROUP, 9 - ILLEGAL_LABEL_GROUP, 10 - } from 'lib/labeling/const' 7 + import {UNKNOWN_LABEL_GROUP, ILLEGAL_LABEL_GROUP} from 'lib/labeling/const' 11 8 12 9 const deviceLocales = getLocales() 13 10 ··· 28 25 } 29 26 30 27 export class PreferencesModel { 31 - _contentLanguages: string[] | undefined 28 + contentLanguages: string[] = 29 + deviceLocales?.map?.(locale => locale.languageCode) || [] 32 30 contentLabels = new LabelPreferencesModel() 33 31 34 32 constructor() { 35 33 makeAutoObservable(this, {}, {autoBind: true}) 36 34 } 37 35 38 - // gives an array of BCP 47 language tags without region codes 39 - get contentLanguages() { 40 - if (this._contentLanguages) { 41 - return this._contentLanguages 42 - } 43 - return deviceLocales.map(locale => locale.languageCode) 44 - } 45 - 46 36 serialize() { 47 37 return { 48 - contentLanguages: this._contentLanguages, 38 + contentLanguages: this.contentLanguages, 49 39 contentLabels: this.contentLabels, 50 40 } 51 41 } ··· 57 47 Array.isArray(v.contentLanguages) && 58 48 typeof v.contentLanguages.every(item => typeof item === 'string') 59 49 ) { 60 - this._contentLanguages = v.contentLanguages 50 + this.contentLanguages = v.contentLanguages 61 51 } 62 52 if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { 63 53 Object.assign(this.contentLabels, v.contentLabels) 54 + } else { 55 + // default to the device languages 56 + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) 64 57 } 58 + } 59 + } 60 + 61 + hasContentLanguage(code2: string) { 62 + return this.contentLanguages.includes(code2) 63 + } 64 + 65 + toggleContentLanguage(code2: string) { 66 + if (this.hasContentLanguage(code2)) { 67 + this.contentLanguages = this.contentLanguages.filter( 68 + lang => lang !== code2, 69 + ) 70 + } else { 71 + this.contentLanguages = this.contentLanguages.concat([code2]) 65 72 } 66 73 } 67 74
+5
src/state/models/ui/shell.ts
··· 85 85 name: 'content-filtering-settings' 86 86 } 87 87 88 + export interface ContentLanguagesSettingsModal { 89 + name: 'content-languages-settings' 90 + } 91 + 88 92 export type Modal = 89 93 // Account 90 94 | AddAppPasswordModal ··· 94 98 95 99 // Curation 96 100 | ContentFilteringSettingsModal 101 + | ContentLanguagesSettingsModal 97 102 98 103 // Reporting 99 104 | ReportAccountModal
+1 -1
src/view/com/modals/ContentFilteringSettings.tsx
··· 21 21 }, [store]) 22 22 23 23 return ( 24 - <View testID="reportPostModal" style={[pal.view, styles.container]}> 24 + <View testID="contentModerationModal" style={[pal.view, styles.container]}> 25 25 <Text style={[pal.text, styles.title]}>Content Moderation</Text> 26 26 <ScrollView style={styles.scrollContainer}> 27 27 <ContentLabelPref group="nsfw" />
+143
src/view/com/modals/ContentLanguagesSettings.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, Pressable, View} from 'react-native' 3 + import LinearGradient from 'react-native-linear-gradient' 4 + import {observer} from 'mobx-react-lite' 5 + import {ScrollView} from './util' 6 + import {useStores} from 'state/index' 7 + import {ToggleButton} from '../util/forms/ToggleButton' 8 + import {s, colors, gradients} from 'lib/styles' 9 + import {Text} from '../util/text/Text' 10 + import {usePalette} from 'lib/hooks/usePalette' 11 + import {isDesktopWeb} from 'platform/detection' 12 + import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../locale/languages' 13 + 14 + export const snapPoints = ['100%'] 15 + 16 + export function Component({}: {}) { 17 + const store = useStores() 18 + const pal = usePalette('default') 19 + const onPressDone = React.useCallback(() => { 20 + store.shell.closeModal() 21 + }, [store]) 22 + 23 + const languages = React.useMemo(() => { 24 + const langs = LANGUAGES.filter( 25 + lang => 26 + !!lang.code2.trim() && 27 + LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3, 28 + ) 29 + // sort so that selected languages are on top, then alphabetically 30 + langs.sort((a, b) => { 31 + const hasA = store.preferences.hasContentLanguage(a.code2) 32 + const hasB = store.preferences.hasContentLanguage(b.code2) 33 + if (hasA === hasB) return a.name.localeCompare(b.name) 34 + if (hasA) return -1 35 + return 1 36 + }) 37 + return langs 38 + }, [store]) 39 + 40 + return ( 41 + <View testID="contentLanguagesModal" style={[pal.view, styles.container]}> 42 + <Text style={[pal.text, styles.title]}>Content Languages</Text> 43 + <Text style={[pal.text, styles.description]}> 44 + Which languages would you like to see in the What's Hot feed? (Leave 45 + them all unchecked to see any language.) 46 + </Text> 47 + <ScrollView style={styles.scrollContainer}> 48 + {languages.map(lang => ( 49 + <LanguageToggle 50 + key={lang.code2} 51 + code2={lang.code2} 52 + name={lang.name} 53 + /> 54 + ))} 55 + <View style={styles.bottomSpacer} /> 56 + </ScrollView> 57 + <View style={[styles.btnContainer, pal.borderDark]}> 58 + <Pressable 59 + testID="sendReportBtn" 60 + onPress={onPressDone} 61 + accessibilityRole="button" 62 + accessibilityLabel="Confirm content language settings" 63 + accessibilityHint=""> 64 + <LinearGradient 65 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 66 + start={{x: 0, y: 0}} 67 + end={{x: 1, y: 1}} 68 + style={[styles.btn]}> 69 + <Text style={[s.white, s.bold, s.f18]}>Done</Text> 70 + </LinearGradient> 71 + </Pressable> 72 + </View> 73 + </View> 74 + ) 75 + } 76 + 77 + const LanguageToggle = observer( 78 + ({code2, name}: {code2: string; name: string}) => { 79 + const store = useStores() 80 + const pal = usePalette('default') 81 + 82 + const onPress = React.useCallback(() => { 83 + store.preferences.toggleContentLanguage(code2) 84 + }, [store, code2]) 85 + 86 + return ( 87 + <ToggleButton 88 + label={name} 89 + isSelected={store.preferences.contentLanguages.includes(code2)} 90 + onPress={onPress} 91 + style={[pal.border, styles.languageToggle]} 92 + /> 93 + ) 94 + }, 95 + ) 96 + 97 + const styles = StyleSheet.create({ 98 + container: { 99 + flex: 1, 100 + paddingTop: 20, 101 + }, 102 + title: { 103 + textAlign: 'center', 104 + fontWeight: 'bold', 105 + fontSize: 24, 106 + marginBottom: 12, 107 + }, 108 + description: { 109 + textAlign: 'center', 110 + paddingHorizontal: 16, 111 + marginBottom: 10, 112 + }, 113 + scrollContainer: { 114 + flex: 1, 115 + paddingHorizontal: 10, 116 + }, 117 + bottomSpacer: { 118 + height: isDesktopWeb ? 0 : 60, 119 + }, 120 + btnContainer: { 121 + paddingTop: 10, 122 + paddingHorizontal: 10, 123 + paddingBottom: isDesktopWeb ? 0 : 40, 124 + borderTopWidth: isDesktopWeb ? 0 : 1, 125 + }, 126 + 127 + languageToggle: { 128 + borderTopWidth: 1, 129 + borderRadius: 0, 130 + paddingHorizontal: 0, 131 + paddingVertical: 12, 132 + }, 133 + 134 + btn: { 135 + flexDirection: 'row', 136 + alignItems: 'center', 137 + justifyContent: 'center', 138 + width: '100%', 139 + borderRadius: 32, 140 + padding: 14, 141 + backgroundColor: colors.gray1, 142 + }, 143 + })
+4
src/view/com/modals/Modal.tsx
··· 21 21 import * as InviteCodesModal from './InviteCodes' 22 22 import * as AddAppPassword from './AddAppPasswords' 23 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 + import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' 24 25 25 26 const DEFAULT_SNAPPOINTS = ['90%'] 26 27 ··· 93 94 } else if (activeModal?.name === 'content-filtering-settings') { 94 95 snapPoints = ContentFilteringSettingsModal.snapPoints 95 96 element = <ContentFilteringSettingsModal.Component /> 97 + } else if (activeModal?.name === 'content-languages-settings') { 98 + snapPoints = ContentLanguagesSettingsModal.snapPoints 99 + element = <ContentLanguagesSettingsModal.Component /> 96 100 } else { 97 101 return null 98 102 }
+3
src/view/com/modals/Modal.web.tsx
··· 21 21 import * as InviteCodesModal from './InviteCodes' 22 22 import * as AddAppPassword from './AddAppPasswords' 23 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 + import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' 24 25 25 26 export const ModalsContainer = observer(function ModalsContainer() { 26 27 const store = useStores() ··· 84 85 element = <AddAppPassword.Component /> 85 86 } else if (modal.name === 'content-filtering-settings') { 86 87 element = <ContentFilteringSettingsModal.Component /> 88 + } else if (modal.name === 'content-languages-settings') { 89 + element = <ContentLanguagesSettingsModal.Component /> 87 90 } else if (modal.name === 'alt-text-image') { 88 91 element = <AltTextImageModal.Component {...modal} /> 89 92 } else if (modal.name === 'alt-text-image-read') {
-1
src/view/com/posts/FollowingEmptyState.tsx
··· 48 48 } 49 49 const styles = StyleSheet.create({ 50 50 emptyContainer: { 51 - // flex: 1, 52 51 height: '100%', 53 52 paddingVertical: 40, 54 53 paddingHorizontal: 30,
+76
src/view/com/posts/WhatsHotEmptyState.tsx
··· 1 + import React from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import { 4 + FontAwesomeIcon, 5 + FontAwesomeIconStyle, 6 + } from '@fortawesome/react-native-fontawesome' 7 + import {Text} from '../util/text/Text' 8 + import {Button} from '../util/forms/Button' 9 + import {MagnifyingGlassIcon} from 'lib/icons' 10 + import {useStores} from 'state/index' 11 + import {usePalette} from 'lib/hooks/usePalette' 12 + import {s} from 'lib/styles' 13 + 14 + export function WhatsHotEmptyState() { 15 + const pal = usePalette('default') 16 + const palInverted = usePalette('inverted') 17 + const store = useStores() 18 + 19 + const onPressSettings = React.useCallback(() => { 20 + store.shell.openModal({name: 'content-languages-settings'}) 21 + }, [store]) 22 + 23 + return ( 24 + <View style={styles.emptyContainer}> 25 + <View style={styles.emptyIconContainer}> 26 + <MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} /> 27 + </View> 28 + <Text type="xl-medium" style={[s.textCenter, pal.text]}> 29 + Your What's Hot feed is empty! This is because there aren't enough users 30 + posting in your selected language. 31 + </Text> 32 + <Button type="inverted" style={styles.emptyBtn} onPress={onPressSettings}> 33 + <Text type="lg-medium" style={palInverted.text}> 34 + Update my settings 35 + </Text> 36 + <FontAwesomeIcon 37 + icon="angle-right" 38 + style={palInverted.text as FontAwesomeIconStyle} 39 + size={14} 40 + /> 41 + </Button> 42 + </View> 43 + ) 44 + } 45 + const styles = StyleSheet.create({ 46 + emptyContainer: { 47 + height: '100%', 48 + paddingVertical: 40, 49 + paddingHorizontal: 30, 50 + }, 51 + emptyIconContainer: { 52 + marginBottom: 16, 53 + }, 54 + emptyIcon: { 55 + marginLeft: 'auto', 56 + marginRight: 'auto', 57 + }, 58 + emptyBtn: { 59 + marginVertical: 20, 60 + flexDirection: 'row', 61 + alignItems: 'center', 62 + justifyContent: 'space-between', 63 + paddingVertical: 18, 64 + paddingHorizontal: 24, 65 + borderRadius: 30, 66 + }, 67 + 68 + feedsTip: { 69 + position: 'absolute', 70 + left: 22, 71 + }, 72 + feedsTipArrow: { 73 + marginLeft: 32, 74 + marginTop: 8, 75 + }, 76 + })
+84 -66
src/view/screens/Home.tsx
··· 9 9 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' 10 10 import {Feed} from '../com/posts/Feed' 11 11 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' 12 + import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' 12 13 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 13 14 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 14 15 import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' ··· 24 25 const POLL_FREQ = 30e3 // 30sec 25 26 26 27 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 27 - export const HomeScreen = withAuthRequired((_opts: Props) => { 28 - const store = useStores() 29 - const [selectedPage, setSelectedPage] = React.useState(0) 28 + export const HomeScreen = withAuthRequired( 29 + observer((_opts: Props) => { 30 + const store = useStores() 31 + const [selectedPage, setSelectedPage] = React.useState(0) 32 + const [initialLanguages] = React.useState( 33 + store.preferences.contentLanguages, 34 + ) 30 35 31 - const algoFeed = React.useMemo(() => { 32 - const feed = new PostsFeedModel(store, 'goodstuff', {}) 33 - feed.setup() 34 - return feed 35 - }, [store]) 36 + const algoFeed: PostsFeedModel = React.useMemo(() => { 37 + const feed = new PostsFeedModel(store, 'goodstuff', {}) 38 + feed.setup() 39 + return feed 40 + }, [store]) 36 41 37 - useFocusEffect( 38 - React.useCallback(() => { 39 - store.shell.setMinimalShellMode(false) 40 - store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) 41 - return () => { 42 - store.shell.setIsDrawerSwipeDisabled(false) 42 + React.useEffect(() => { 43 + // refresh whats hot when lang preferences change 44 + if (initialLanguages !== store.preferences.contentLanguages) { 45 + algoFeed.refresh() 43 46 } 44 - }, [store, selectedPage]), 45 - ) 47 + }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) 46 48 47 - const onPageSelected = React.useCallback( 48 - (index: number) => { 49 - store.shell.setMinimalShellMode(false) 50 - setSelectedPage(index) 51 - store.shell.setIsDrawerSwipeDisabled(index > 0) 52 - }, 53 - [store], 54 - ) 49 + useFocusEffect( 50 + React.useCallback(() => { 51 + store.shell.setMinimalShellMode(false) 52 + store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) 53 + return () => { 54 + store.shell.setIsDrawerSwipeDisabled(false) 55 + } 56 + }, [store, selectedPage]), 57 + ) 55 58 56 - const onPressSelected = React.useCallback(() => { 57 - store.emitScreenSoftReset() 58 - }, [store]) 59 + const onPageSelected = React.useCallback( 60 + (index: number) => { 61 + store.shell.setMinimalShellMode(false) 62 + setSelectedPage(index) 63 + store.shell.setIsDrawerSwipeDisabled(index > 0) 64 + }, 65 + [store], 66 + ) 59 67 60 - const renderTabBar = React.useCallback( 61 - (props: RenderTabBarFnProps) => { 62 - return ( 63 - <FeedsTabBar 64 - {...props} 65 - testID="homeScreenFeedTabs" 66 - onPressSelected={onPressSelected} 67 - /> 68 - ) 69 - }, 70 - [onPressSelected], 71 - ) 68 + const onPressSelected = React.useCallback(() => { 69 + store.emitScreenSoftReset() 70 + }, [store]) 72 71 73 - const renderFollowingEmptyState = React.useCallback(() => { 74 - return <FollowingEmptyState /> 75 - }, []) 72 + const renderTabBar = React.useCallback( 73 + (props: RenderTabBarFnProps) => { 74 + return ( 75 + <FeedsTabBar 76 + {...props} 77 + testID="homeScreenFeedTabs" 78 + onPressSelected={onPressSelected} 79 + /> 80 + ) 81 + }, 82 + [onPressSelected], 83 + ) 76 84 77 - const initialPage = store.me.followsCount === 0 ? 1 : 0 78 - return ( 79 - <Pager 80 - testID="homeScreen" 81 - onPageSelected={onPageSelected} 82 - renderTabBar={renderTabBar} 83 - tabBarPosition="top" 84 - initialPage={initialPage}> 85 - <FeedPage 86 - key="1" 87 - testID="followingFeedPage" 88 - isPageFocused={selectedPage === 0} 89 - feed={store.me.mainFeed} 90 - renderEmptyState={renderFollowingEmptyState} 91 - /> 92 - <FeedPage 93 - key="2" 94 - testID="whatshotFeedPage" 95 - isPageFocused={selectedPage === 1} 96 - feed={algoFeed} 97 - /> 98 - </Pager> 99 - ) 100 - }) 85 + const renderFollowingEmptyState = React.useCallback(() => { 86 + return <FollowingEmptyState /> 87 + }, []) 88 + 89 + const renderWhatsHotEmptyState = React.useCallback(() => { 90 + return <WhatsHotEmptyState /> 91 + }, []) 92 + 93 + const initialPage = store.me.followsCount === 0 ? 1 : 0 94 + return ( 95 + <Pager 96 + testID="homeScreen" 97 + onPageSelected={onPageSelected} 98 + renderTabBar={renderTabBar} 99 + tabBarPosition="top" 100 + initialPage={initialPage}> 101 + <FeedPage 102 + key="1" 103 + testID="followingFeedPage" 104 + isPageFocused={selectedPage === 0} 105 + feed={store.me.mainFeed} 106 + renderEmptyState={renderFollowingEmptyState} 107 + /> 108 + <FeedPage 109 + key="2" 110 + testID="whatshotFeedPage" 111 + isPageFocused={selectedPage === 1} 112 + feed={algoFeed} 113 + renderEmptyState={renderWhatsHotEmptyState} 114 + /> 115 + </Pager> 116 + ) 117 + }), 118 + ) 101 119 102 120 const FeedPage = observer( 103 121 ({
+23 -1
src/view/screens/Settings.tsx
··· 131 131 store.shell.openModal({name: 'content-filtering-settings'}) 132 132 }, [track, store]) 133 133 134 + const onPressContentLanguages = React.useCallback(() => { 135 + track('Settings:ContentlanguagesButtonClicked') 136 + store.shell.openModal({name: 'content-languages-settings'}) 137 + }, [track, store]) 138 + 134 139 const onPressSignout = React.useCallback(() => { 135 140 track('Settings:SignOutButtonClicked') 136 141 store.session.logout() ··· 312 317 /> 313 318 </View> 314 319 <Text type="lg" style={pal.text}> 315 - App Passwords 320 + App passwords 316 321 </Text> 317 322 </Link> 323 + <TouchableOpacity 324 + testID="contentLanguagesBtn" 325 + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} 326 + onPress={isSwitching ? undefined : onPressContentLanguages} 327 + accessibilityRole="button" 328 + accessibilityHint="Content languages" 329 + accessibilityLabel="Opens configurable content language settings"> 330 + <View style={[styles.iconContainer, pal.btn]}> 331 + <FontAwesomeIcon 332 + icon="language" 333 + style={pal.text as FontAwesomeIconStyle} 334 + /> 335 + </View> 336 + <Text type="lg" style={pal.text}> 337 + Content languages 338 + </Text> 339 + </TouchableOpacity> 318 340 <TouchableOpacity 319 341 testID="changeHandleBtn" 320 342 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}