From 6f1c4ec9a9b5b7d0b4b67f3bc7c3af6810da5001 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 2 May 2023 23:06:55 -0500 Subject: [PATCH] [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 --- __mocks__/expo-localization.js | 1 + src/lib/api/feed-manip.ts | 10 +- src/locale/languages.ts | 2 +- src/state/models/feeds/posts.ts | 12 ++ src/state/models/ui/preferences.ts | 39 +++-- src/state/models/ui/shell.ts | 5 + .../com/modals/ContentFilteringSettings.tsx | 2 +- .../com/modals/ContentLanguagesSettings.tsx | 143 ++++++++++++++++ src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/com/posts/FollowingEmptyState.tsx | 1 - src/view/com/posts/WhatsHotEmptyState.tsx | 76 +++++++++ src/view/screens/Home.tsx | 152 ++++++++++-------- src/view/screens/Settings.tsx | 24 ++- 14 files changed, 381 insertions(+), 93 deletions(-) create mode 100644 __mocks__/expo-localization.js create mode 100644 src/view/com/modals/ContentLanguagesSettings.tsx create mode 100644 src/view/com/posts/WhatsHotEmptyState.tsx diff --git a/__mocks__/expo-localization.js b/__mocks__/expo-localization.js new file mode 100644 index 00000000..8bd537cf --- /dev/null +++ b/__mocks__/expo-localization.js @@ -0,0 +1 @@ +export const getLocales = jest.fn().mockResolvedValue([]) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 60e75504..96534d1b 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -202,7 +202,9 @@ export class FeedTuner { tuner: FeedTuner, slices: FeedViewPostsSlice[], ): FeedViewPostsSlice[] => { - const origSlices = slices.concat() + if (!langsCode2.length) { + return slices + } for (let i = slices.length - 1; i >= 0; i--) { let hasPreferredLang = false for (const item of slices[i].items) { @@ -236,11 +238,7 @@ export class FeedTuner { slices.splice(i, 1) } } - if (slices.length) { - return slices - } - // fallback: give everything if the language filter left nothing - return origSlices + return slices } } } diff --git a/src/locale/languages.ts b/src/locale/languages.ts index 31c1a9f7..269e2fa9 100644 --- a/src/locale/languages.ts +++ b/src/locale/languages.ts @@ -23,7 +23,7 @@ export const LANGUAGES: Language[] = [ {code3: 'alt', code2: '', name: 'Southern Altai'}, {code3: 'amh', code2: 'am', name: 'Amharic'}, {code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'}, - {code3: 'anp ', code2: 'Angika', name: 'angika'}, + {code3: 'anp ', code2: 'Angika', name: 'Angika'}, {code3: 'apa', code2: '', name: 'Apache languages'}, {code3: 'ara', code2: 'ar', name: 'Arabic'}, { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 62047acb..44cec3af 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -297,6 +297,9 @@ export class PostsFeedModel { // used to linearize async modifications to state lock = new AwaitLock() + // used to track if what's hot is coming up empty + emptyFetches = 0 + // data slices: PostsFeedSliceModel[] = [] @@ -603,6 +606,9 @@ export class PostsFeedModel { ) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor + if (replace) { + this.emptyFetches = 0 + } this.rootStore.me.follows.hydrateProfiles( res.data.feed.map(item => item.post.author), @@ -625,6 +631,12 @@ export class PostsFeedModel { } else { this.slices = this.slices.concat(toAppend) } + if (toAppend.length === 0) { + this.emptyFetches++ + if (this.emptyFetches >= 10) { + this.hasMore = false + } + } }) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index ae3f712c..f6b29169 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -2,12 +2,9 @@ import {makeAutoObservable} from 'mobx' import {getLocales} from 'expo-localization' import {isObj, hasProp} from 'lib/type-guards' import {ComAtprotoLabelDefs} from '@atproto/api' +import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' -import { - LabelValGroup, - UNKNOWN_LABEL_GROUP, - ILLEGAL_LABEL_GROUP, -} from 'lib/labeling/const' +import {UNKNOWN_LABEL_GROUP, ILLEGAL_LABEL_GROUP} from 'lib/labeling/const' const deviceLocales = getLocales() @@ -28,24 +25,17 @@ export class LabelPreferencesModel { } export class PreferencesModel { - _contentLanguages: string[] | undefined + contentLanguages: string[] = + deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() constructor() { makeAutoObservable(this, {}, {autoBind: true}) } - // gives an array of BCP 47 language tags without region codes - get contentLanguages() { - if (this._contentLanguages) { - return this._contentLanguages - } - return deviceLocales.map(locale => locale.languageCode) - } - serialize() { return { - contentLanguages: this._contentLanguages, + contentLanguages: this.contentLanguages, contentLabels: this.contentLabels, } } @@ -57,14 +47,31 @@ export class PreferencesModel { Array.isArray(v.contentLanguages) && typeof v.contentLanguages.every(item => typeof item === 'string') ) { - this._contentLanguages = v.contentLanguages + this.contentLanguages = v.contentLanguages } if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { Object.assign(this.contentLabels, v.contentLabels) + } else { + // default to the device languages + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) } } } + hasContentLanguage(code2: string) { + return this.contentLanguages.includes(code2) + } + + toggleContentLanguage(code2: string) { + if (this.hasContentLanguage(code2)) { + this.contentLanguages = this.contentLanguages.filter( + lang => lang !== code2, + ) + } else { + this.contentLanguages = this.contentLanguages.concat([code2]) + } + } + setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 0b0da000..dea220c5 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -85,6 +85,10 @@ export interface ContentFilteringSettingsModal { name: 'content-filtering-settings' } +export interface ContentLanguagesSettingsModal { + name: 'content-languages-settings' +} + export type Modal = // Account | AddAppPasswordModal @@ -94,6 +98,7 @@ export type Modal = // Curation | ContentFilteringSettingsModal + | ContentLanguagesSettingsModal // Reporting | ReportAccountModal diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index c683e43f..cfba2575 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -21,7 +21,7 @@ export function Component({}: {}) { }, [store]) return ( - + Content Moderation diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx new file mode 100644 index 00000000..0c750fe0 --- /dev/null +++ b/src/view/com/modals/ContentLanguagesSettings.tsx @@ -0,0 +1,143 @@ +import React from 'react' +import {StyleSheet, Pressable, View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {observer} from 'mobx-react-lite' +import {ScrollView} from './util' +import {useStores} from 'state/index' +import {ToggleButton} from '../util/forms/ToggleButton' +import {s, colors, gradients} from 'lib/styles' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../locale/languages' + +export const snapPoints = ['100%'] + +export function Component({}: {}) { + const store = useStores() + const pal = usePalette('default') + const onPressDone = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + + const languages = React.useMemo(() => { + const langs = LANGUAGES.filter( + lang => + !!lang.code2.trim() && + LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3, + ) + // sort so that selected languages are on top, then alphabetically + langs.sort((a, b) => { + const hasA = store.preferences.hasContentLanguage(a.code2) + const hasB = store.preferences.hasContentLanguage(b.code2) + if (hasA === hasB) return a.name.localeCompare(b.name) + if (hasA) return -1 + return 1 + }) + return langs + }, [store]) + + return ( + + Content Languages + + Which languages would you like to see in the What's Hot feed? (Leave + them all unchecked to see any language.) + + + {languages.map(lang => ( + + ))} + + + + + + Done + + + + + ) +} + +const LanguageToggle = observer( + ({code2, name}: {code2: string; name: string}) => { + const store = useStores() + const pal = usePalette('default') + + const onPress = React.useCallback(() => { + store.preferences.toggleContentLanguage(code2) + }, [store, code2]) + + return ( + + ) + }, +) + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 20, + }, + title: { + textAlign: 'center', + fontWeight: 'bold', + fontSize: 24, + marginBottom: 12, + }, + description: { + textAlign: 'center', + paddingHorizontal: 16, + marginBottom: 10, + }, + scrollContainer: { + flex: 1, + paddingHorizontal: 10, + }, + bottomSpacer: { + height: isDesktopWeb ? 0 : 60, + }, + btnContainer: { + paddingTop: 10, + paddingHorizontal: 10, + paddingBottom: isDesktopWeb ? 0 : 40, + borderTopWidth: isDesktopWeb ? 0 : 1, + }, + + languageToggle: { + borderTopWidth: 1, + borderRadius: 0, + paddingHorizontal: 0, + paddingVertical: 12, + }, + + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 14, + backgroundColor: colors.gray1, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2e053e3a..b5d71a11 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -21,6 +21,7 @@ import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' +import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' const DEFAULT_SNAPPOINTS = ['90%'] @@ -93,6 +94,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'content-filtering-settings') { snapPoints = ContentFilteringSettingsModal.snapPoints element = + } else if (activeModal?.name === 'content-languages-settings') { + snapPoints = ContentLanguagesSettingsModal.snapPoints + element = } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e850c9f2..50487e3e 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -21,6 +21,7 @@ import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' +import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -84,6 +85,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'content-filtering-settings') { element = + } else if (modal.name === 'content-languages-settings') { + element = } else if (modal.name === 'alt-text-image') { element = } else if (modal.name === 'alt-text-image-read') { diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx index acd035f2..b3729817 100644 --- a/src/view/com/posts/FollowingEmptyState.tsx +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -48,7 +48,6 @@ export function FollowingEmptyState() { } const styles = StyleSheet.create({ emptyContainer: { - // flex: 1, height: '100%', paddingVertical: 40, paddingHorizontal: 30, diff --git a/src/view/com/posts/WhatsHotEmptyState.tsx b/src/view/com/posts/WhatsHotEmptyState.tsx new file mode 100644 index 00000000..ade94ca3 --- /dev/null +++ b/src/view/com/posts/WhatsHotEmptyState.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {MagnifyingGlassIcon} from 'lib/icons' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export function WhatsHotEmptyState() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + + const onPressSettings = React.useCallback(() => { + store.shell.openModal({name: 'content-languages-settings'}) + }, [store]) + + return ( + + + + + + Your What's Hot feed is empty! This is because there aren't enough users + posting in your selected language. + + + + ) +} +const styles = StyleSheet.create({ + emptyContainer: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + emptyIconContainer: { + marginBottom: 16, + }, + emptyIcon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, + + feedsTip: { + position: 'absolute', + left: 22, + }, + feedsTipArrow: { + marginLeft: 32, + marginTop: 8, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index ba9b05c4..2b102ae3 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -9,6 +9,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' +import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' @@ -24,80 +25,97 @@ const HEADER_OFFSET = isDesktopWeb ? 50 : 40 const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps -export const HomeScreen = withAuthRequired((_opts: Props) => { - const store = useStores() - const [selectedPage, setSelectedPage] = React.useState(0) +export const HomeScreen = withAuthRequired( + observer((_opts: Props) => { + const store = useStores() + const [selectedPage, setSelectedPage] = React.useState(0) + const [initialLanguages] = React.useState( + store.preferences.contentLanguages, + ) - const algoFeed = React.useMemo(() => { - const feed = new PostsFeedModel(store, 'goodstuff', {}) - feed.setup() - return feed - }, [store]) + const algoFeed: PostsFeedModel = React.useMemo(() => { + const feed = new PostsFeedModel(store, 'goodstuff', {}) + feed.setup() + return feed + }, [store]) - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) - return () => { - store.shell.setIsDrawerSwipeDisabled(false) + React.useEffect(() => { + // refresh whats hot when lang preferences change + if (initialLanguages !== store.preferences.contentLanguages) { + algoFeed.refresh() } - }, [store, selectedPage]), - ) + }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) - const onPageSelected = React.useCallback( - (index: number) => { - store.shell.setMinimalShellMode(false) - setSelectedPage(index) - store.shell.setIsDrawerSwipeDisabled(index > 0) - }, - [store], - ) + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) + return () => { + store.shell.setIsDrawerSwipeDisabled(false) + } + }, [store, selectedPage]), + ) - const onPressSelected = React.useCallback(() => { - store.emitScreenSoftReset() - }, [store]) + const onPageSelected = React.useCallback( + (index: number) => { + store.shell.setMinimalShellMode(false) + setSelectedPage(index) + store.shell.setIsDrawerSwipeDisabled(index > 0) + }, + [store], + ) - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { - return ( - { + store.emitScreenSoftReset() + }, [store]) + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + + ) + }, + [onPressSelected], + ) + + const renderFollowingEmptyState = React.useCallback(() => { + return + }, []) + + const renderWhatsHotEmptyState = React.useCallback(() => { + return + }, []) + + const initialPage = store.me.followsCount === 0 ? 1 : 0 + return ( + + - ) - }, - [onPressSelected], - ) - - const renderFollowingEmptyState = React.useCallback(() => { - return - }, []) - - const initialPage = store.me.followsCount === 0 ? 1 : 0 - return ( - - - - - ) -}) + + + ) + }), +) const FeedPage = observer( ({ diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 705c37b3..7c48ce96 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -131,6 +131,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'content-filtering-settings'}) }, [track, store]) + const onPressContentLanguages = React.useCallback(() => { + track('Settings:ContentlanguagesButtonClicked') + store.shell.openModal({name: 'content-languages-settings'}) + }, [track, store]) + const onPressSignout = React.useCallback(() => { track('Settings:SignOutButtonClicked') store.session.logout() @@ -312,9 +317,26 @@ export const SettingsScreen = withAuthRequired( /> - App Passwords + App passwords + + + + + + Content languages + +