tangled mirror of catsky-🐱 Soothing soft social-app fork with all the niche toggles! (Unofficial); for issues and PRs please put them on github:NekoDrone/catsky-social

[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 tuner: FeedTuner, 203 slices: FeedViewPostsSlice[], 204 ): FeedViewPostsSlice[] => { 205 - const origSlices = slices.concat() 206 for (let i = slices.length - 1; i >= 0; i--) { 207 let hasPreferredLang = false 208 for (const item of slices[i].items) { ··· 236 slices.splice(i, 1) 237 } 238 } 239 - if (slices.length) { 240 - return slices 241 - } 242 - // fallback: give everything if the language filter left nothing 243 - return origSlices 244 } 245 } 246 }
··· 202 tuner: FeedTuner, 203 slices: FeedViewPostsSlice[], 204 ): FeedViewPostsSlice[] => { 205 + if (!langsCode2.length) { 206 + return slices 207 + } 208 for (let i = slices.length - 1; i >= 0; i--) { 209 let hasPreferredLang = false 210 for (const item of slices[i].items) { ··· 238 slices.splice(i, 1) 239 } 240 } 241 + return slices 242 } 243 } 244 }
+1 -1
src/locale/languages.ts
··· 23 {code3: 'alt', code2: '', name: 'Southern Altai'}, 24 {code3: 'amh', code2: 'am', name: 'Amharic'}, 25 {code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'}, 26 - {code3: 'anp ', code2: 'Angika', name: 'angika'}, 27 {code3: 'apa', code2: '', name: 'Apache languages'}, 28 {code3: 'ara', code2: 'ar', name: 'Arabic'}, 29 {
··· 23 {code3: 'alt', code2: '', name: 'Southern Altai'}, 24 {code3: 'amh', code2: 'am', name: 'Amharic'}, 25 {code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'}, 26 + {code3: 'anp ', code2: 'Angika', name: 'Angika'}, 27 {code3: 'apa', code2: '', name: 'Apache languages'}, 28 {code3: 'ara', code2: 'ar', name: 'Arabic'}, 29 {
+12
src/state/models/feeds/posts.ts
··· 297 // used to linearize async modifications to state 298 lock = new AwaitLock() 299 300 // data 301 slices: PostsFeedSliceModel[] = [] 302 ··· 603 ) { 604 this.loadMoreCursor = res.data.cursor 605 this.hasMore = !!this.loadMoreCursor 606 607 this.rootStore.me.follows.hydrateProfiles( 608 res.data.feed.map(item => item.post.author), ··· 624 this.slices = toAppend 625 } else { 626 this.slices = this.slices.concat(toAppend) 627 } 628 }) 629 }
··· 297 // used to linearize async modifications to state 298 lock = new AwaitLock() 299 300 + // used to track if what's hot is coming up empty 301 + emptyFetches = 0 302 + 303 // data 304 slices: PostsFeedSliceModel[] = [] 305 ··· 606 ) { 607 this.loadMoreCursor = res.data.cursor 608 this.hasMore = !!this.loadMoreCursor 609 + if (replace) { 610 + this.emptyFetches = 0 611 + } 612 613 this.rootStore.me.follows.hydrateProfiles( 614 res.data.feed.map(item => item.post.author), ··· 630 this.slices = toAppend 631 } else { 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 + } 639 } 640 }) 641 }
+23 -16
src/state/models/ui/preferences.ts
··· 2 import {getLocales} from 'expo-localization' 3 import {isObj, hasProp} from 'lib/type-guards' 4 import {ComAtprotoLabelDefs} from '@atproto/api' 5 import {getLabelValueGroup} from 'lib/labeling/helpers' 6 - import { 7 - LabelValGroup, 8 - UNKNOWN_LABEL_GROUP, 9 - ILLEGAL_LABEL_GROUP, 10 - } from 'lib/labeling/const' 11 12 const deviceLocales = getLocales() 13 ··· 28 } 29 30 export class PreferencesModel { 31 - _contentLanguages: string[] | undefined 32 contentLabels = new LabelPreferencesModel() 33 34 constructor() { 35 makeAutoObservable(this, {}, {autoBind: true}) 36 } 37 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 serialize() { 47 return { 48 - contentLanguages: this._contentLanguages, 49 contentLabels: this.contentLabels, 50 } 51 } ··· 57 Array.isArray(v.contentLanguages) && 58 typeof v.contentLanguages.every(item => typeof item === 'string') 59 ) { 60 - this._contentLanguages = v.contentLanguages 61 } 62 if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { 63 Object.assign(this.contentLabels, v.contentLabels) 64 } 65 } 66 } 67
··· 2 import {getLocales} from 'expo-localization' 3 import {isObj, hasProp} from 'lib/type-guards' 4 import {ComAtprotoLabelDefs} from '@atproto/api' 5 + import {LabelValGroup} from 'lib/labeling/types' 6 import {getLabelValueGroup} from 'lib/labeling/helpers' 7 + import {UNKNOWN_LABEL_GROUP, ILLEGAL_LABEL_GROUP} from 'lib/labeling/const' 8 9 const deviceLocales = getLocales() 10 ··· 25 } 26 27 export class PreferencesModel { 28 + contentLanguages: string[] = 29 + deviceLocales?.map?.(locale => locale.languageCode) || [] 30 contentLabels = new LabelPreferencesModel() 31 32 constructor() { 33 makeAutoObservable(this, {}, {autoBind: true}) 34 } 35 36 serialize() { 37 return { 38 + contentLanguages: this.contentLanguages, 39 contentLabels: this.contentLabels, 40 } 41 } ··· 47 Array.isArray(v.contentLanguages) && 48 typeof v.contentLanguages.every(item => typeof item === 'string') 49 ) { 50 + this.contentLanguages = v.contentLanguages 51 } 52 if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { 53 Object.assign(this.contentLabels, v.contentLabels) 54 + } else { 55 + // default to the device languages 56 + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) 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]) 72 } 73 } 74
+5
src/state/models/ui/shell.ts
··· 85 name: 'content-filtering-settings' 86 } 87 88 export type Modal = 89 // Account 90 | AddAppPasswordModal ··· 94 95 // Curation 96 | ContentFilteringSettingsModal 97 98 // Reporting 99 | ReportAccountModal
··· 85 name: 'content-filtering-settings' 86 } 87 88 + export interface ContentLanguagesSettingsModal { 89 + name: 'content-languages-settings' 90 + } 91 + 92 export type Modal = 93 // Account 94 | AddAppPasswordModal ··· 98 99 // Curation 100 | ContentFilteringSettingsModal 101 + | ContentLanguagesSettingsModal 102 103 // Reporting 104 | ReportAccountModal
+1 -1
src/view/com/modals/ContentFilteringSettings.tsx
··· 21 }, [store]) 22 23 return ( 24 - <View testID="reportPostModal" style={[pal.view, styles.container]}> 25 <Text style={[pal.text, styles.title]}>Content Moderation</Text> 26 <ScrollView style={styles.scrollContainer}> 27 <ContentLabelPref group="nsfw" />
··· 21 }, [store]) 22 23 return ( 24 + <View testID="contentModerationModal" style={[pal.view, styles.container]}> 25 <Text style={[pal.text, styles.title]}>Content Moderation</Text> 26 <ScrollView style={styles.scrollContainer}> 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 import * as InviteCodesModal from './InviteCodes' 22 import * as AddAppPassword from './AddAppPasswords' 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 25 const DEFAULT_SNAPPOINTS = ['90%'] 26 ··· 93 } else if (activeModal?.name === 'content-filtering-settings') { 94 snapPoints = ContentFilteringSettingsModal.snapPoints 95 element = <ContentFilteringSettingsModal.Component /> 96 } else { 97 return null 98 }
··· 21 import * as InviteCodesModal from './InviteCodes' 22 import * as AddAppPassword from './AddAppPasswords' 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 + import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' 25 26 const DEFAULT_SNAPPOINTS = ['90%'] 27 ··· 94 } else if (activeModal?.name === 'content-filtering-settings') { 95 snapPoints = ContentFilteringSettingsModal.snapPoints 96 element = <ContentFilteringSettingsModal.Component /> 97 + } else if (activeModal?.name === 'content-languages-settings') { 98 + snapPoints = ContentLanguagesSettingsModal.snapPoints 99 + element = <ContentLanguagesSettingsModal.Component /> 100 } else { 101 return null 102 }
+3
src/view/com/modals/Modal.web.tsx
··· 21 import * as InviteCodesModal from './InviteCodes' 22 import * as AddAppPassword from './AddAppPasswords' 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 25 export const ModalsContainer = observer(function ModalsContainer() { 26 const store = useStores() ··· 84 element = <AddAppPassword.Component /> 85 } else if (modal.name === 'content-filtering-settings') { 86 element = <ContentFilteringSettingsModal.Component /> 87 } else if (modal.name === 'alt-text-image') { 88 element = <AltTextImageModal.Component {...modal} /> 89 } else if (modal.name === 'alt-text-image-read') {
··· 21 import * as InviteCodesModal from './InviteCodes' 22 import * as AddAppPassword from './AddAppPasswords' 23 import * as ContentFilteringSettingsModal from './ContentFilteringSettings' 24 + import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' 25 26 export const ModalsContainer = observer(function ModalsContainer() { 27 const store = useStores() ··· 85 element = <AddAppPassword.Component /> 86 } else if (modal.name === 'content-filtering-settings') { 87 element = <ContentFilteringSettingsModal.Component /> 88 + } else if (modal.name === 'content-languages-settings') { 89 + element = <ContentLanguagesSettingsModal.Component /> 90 } else if (modal.name === 'alt-text-image') { 91 element = <AltTextImageModal.Component {...modal} /> 92 } else if (modal.name === 'alt-text-image-read') {
-1
src/view/com/posts/FollowingEmptyState.tsx
··· 48 } 49 const styles = StyleSheet.create({ 50 emptyContainer: { 51 - // flex: 1, 52 height: '100%', 53 paddingVertical: 40, 54 paddingHorizontal: 30,
··· 48 } 49 const styles = StyleSheet.create({ 50 emptyContainer: { 51 height: '100%', 52 paddingVertical: 40, 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 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' 10 import {Feed} from '../com/posts/Feed' 11 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' 12 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 13 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 14 import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' ··· 24 const POLL_FREQ = 30e3 // 30sec 25 26 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 27 - export const HomeScreen = withAuthRequired((_opts: Props) => { 28 - const store = useStores() 29 - const [selectedPage, setSelectedPage] = React.useState(0) 30 31 - const algoFeed = React.useMemo(() => { 32 - const feed = new PostsFeedModel(store, 'goodstuff', {}) 33 - feed.setup() 34 - return feed 35 - }, [store]) 36 37 - useFocusEffect( 38 - React.useCallback(() => { 39 - store.shell.setMinimalShellMode(false) 40 - store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) 41 - return () => { 42 - store.shell.setIsDrawerSwipeDisabled(false) 43 } 44 - }, [store, selectedPage]), 45 - ) 46 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 - ) 55 56 - const onPressSelected = React.useCallback(() => { 57 - store.emitScreenSoftReset() 58 - }, [store]) 59 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 - ) 72 73 - const renderFollowingEmptyState = React.useCallback(() => { 74 - return <FollowingEmptyState /> 75 - }, []) 76 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 - }) 101 102 const FeedPage = observer( 103 ({
··· 9 import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' 10 import {Feed} from '../com/posts/Feed' 11 import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' 12 + import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' 13 import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' 14 import {FeedsTabBar} from '../com/pager/FeedsTabBar' 15 import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' ··· 25 const POLL_FREQ = 30e3 // 30sec 26 27 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> 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 + ) 35 36 + const algoFeed: PostsFeedModel = React.useMemo(() => { 37 + const feed = new PostsFeedModel(store, 'goodstuff', {}) 38 + feed.setup() 39 + return feed 40 + }, [store]) 41 42 + React.useEffect(() => { 43 + // refresh whats hot when lang preferences change 44 + if (initialLanguages !== store.preferences.contentLanguages) { 45 + algoFeed.refresh() 46 } 47 + }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) 48 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 + ) 58 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 + ) 67 68 + const onPressSelected = React.useCallback(() => { 69 + store.emitScreenSoftReset() 70 + }, [store]) 71 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 + ) 84 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 + ) 119 120 const FeedPage = observer( 121 ({
+23 -1
src/view/screens/Settings.tsx
··· 131 store.shell.openModal({name: 'content-filtering-settings'}) 132 }, [track, store]) 133 134 const onPressSignout = React.useCallback(() => { 135 track('Settings:SignOutButtonClicked') 136 store.session.logout() ··· 312 /> 313 </View> 314 <Text type="lg" style={pal.text}> 315 - App Passwords 316 </Text> 317 </Link> 318 <TouchableOpacity 319 testID="changeHandleBtn" 320 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
··· 131 store.shell.openModal({name: 'content-filtering-settings'}) 132 }, [track, store]) 133 134 + const onPressContentLanguages = React.useCallback(() => { 135 + track('Settings:ContentlanguagesButtonClicked') 136 + store.shell.openModal({name: 'content-languages-settings'}) 137 + }, [track, store]) 138 + 139 const onPressSignout = React.useCallback(() => { 140 track('Settings:SignOutButtonClicked') 141 store.session.logout() ··· 317 /> 318 </View> 319 <Text type="lg" style={pal.text}> 320 + App passwords 321 </Text> 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> 340 <TouchableOpacity 341 testID="changeHandleBtn" 342 style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}