[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
zio/stable
Paul Frazee 2023-05-02 23:06:55 -05:00 committed by GitHub
parent 95f8360d19
commit 6f1c4ec9a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 381 additions and 93 deletions

View File

@ -0,0 +1 @@
export const getLocales = jest.fn().mockResolvedValue([])

View File

@ -202,7 +202,9 @@ export class FeedTuner {
tuner: FeedTuner, tuner: FeedTuner,
slices: FeedViewPostsSlice[], slices: FeedViewPostsSlice[],
): FeedViewPostsSlice[] => { ): FeedViewPostsSlice[] => {
const origSlices = slices.concat() if (!langsCode2.length) {
return slices
}
for (let i = slices.length - 1; i >= 0; i--) { for (let i = slices.length - 1; i >= 0; i--) {
let hasPreferredLang = false let hasPreferredLang = false
for (const item of slices[i].items) { for (const item of slices[i].items) {
@ -236,12 +238,8 @@ export class FeedTuner {
slices.splice(i, 1) slices.splice(i, 1)
} }
} }
if (slices.length) {
return slices return slices
} }
// fallback: give everything if the language filter left nothing
return origSlices
}
} }
} }

View File

@ -23,7 +23,7 @@ export const LANGUAGES: Language[] = [
{code3: 'alt', code2: '', name: 'Southern Altai'}, {code3: 'alt', code2: '', name: 'Southern Altai'},
{code3: 'amh', code2: 'am', name: 'Amharic'}, {code3: 'amh', code2: 'am', name: 'Amharic'},
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'}, {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: 'apa', code2: '', name: 'Apache languages'},
{code3: 'ara', code2: 'ar', name: 'Arabic'}, {code3: 'ara', code2: 'ar', name: 'Arabic'},
{ {

View File

@ -297,6 +297,9 @@ export class PostsFeedModel {
// used to linearize async modifications to state // used to linearize async modifications to state
lock = new AwaitLock() lock = new AwaitLock()
// used to track if what's hot is coming up empty
emptyFetches = 0
// data // data
slices: PostsFeedSliceModel[] = [] slices: PostsFeedSliceModel[] = []
@ -603,6 +606,9 @@ export class PostsFeedModel {
) { ) {
this.loadMoreCursor = res.data.cursor this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor this.hasMore = !!this.loadMoreCursor
if (replace) {
this.emptyFetches = 0
}
this.rootStore.me.follows.hydrateProfiles( this.rootStore.me.follows.hydrateProfiles(
res.data.feed.map(item => item.post.author), res.data.feed.map(item => item.post.author),
@ -625,6 +631,12 @@ export class PostsFeedModel {
} else { } else {
this.slices = this.slices.concat(toAppend) this.slices = this.slices.concat(toAppend)
} }
if (toAppend.length === 0) {
this.emptyFetches++
if (this.emptyFetches >= 10) {
this.hasMore = false
}
}
}) })
} }

View File

@ -2,12 +2,9 @@ import {makeAutoObservable} from 'mobx'
import {getLocales} from 'expo-localization' import {getLocales} from 'expo-localization'
import {isObj, hasProp} from 'lib/type-guards' import {isObj, hasProp} from 'lib/type-guards'
import {ComAtprotoLabelDefs} from '@atproto/api' import {ComAtprotoLabelDefs} from '@atproto/api'
import {LabelValGroup} from 'lib/labeling/types'
import {getLabelValueGroup} from 'lib/labeling/helpers' import {getLabelValueGroup} from 'lib/labeling/helpers'
import { import {UNKNOWN_LABEL_GROUP, ILLEGAL_LABEL_GROUP} from 'lib/labeling/const'
LabelValGroup,
UNKNOWN_LABEL_GROUP,
ILLEGAL_LABEL_GROUP,
} from 'lib/labeling/const'
const deviceLocales = getLocales() const deviceLocales = getLocales()
@ -28,24 +25,17 @@ export class LabelPreferencesModel {
} }
export class PreferencesModel { export class PreferencesModel {
_contentLanguages: string[] | undefined contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || []
contentLabels = new LabelPreferencesModel() contentLabels = new LabelPreferencesModel()
constructor() { constructor() {
makeAutoObservable(this, {}, {autoBind: true}) 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() { serialize() {
return { return {
contentLanguages: this._contentLanguages, contentLanguages: this.contentLanguages,
contentLabels: this.contentLabels, contentLabels: this.contentLabels,
} }
} }
@ -57,14 +47,31 @@ export class PreferencesModel {
Array.isArray(v.contentLanguages) && Array.isArray(v.contentLanguages) &&
typeof v.contentLanguages.every(item => typeof item === 'string') typeof v.contentLanguages.every(item => typeof item === 'string')
) { ) {
this._contentLanguages = v.contentLanguages this.contentLanguages = v.contentLanguages
} }
if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
Object.assign(this.contentLabels, v.contentLabels) 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( setContentLabelPref(
key: keyof LabelPreferencesModel, key: keyof LabelPreferencesModel,
value: LabelPreference, value: LabelPreference,

View File

@ -85,6 +85,10 @@ export interface ContentFilteringSettingsModal {
name: 'content-filtering-settings' name: 'content-filtering-settings'
} }
export interface ContentLanguagesSettingsModal {
name: 'content-languages-settings'
}
export type Modal = export type Modal =
// Account // Account
| AddAppPasswordModal | AddAppPasswordModal
@ -94,6 +98,7 @@ export type Modal =
// Curation // Curation
| ContentFilteringSettingsModal | ContentFilteringSettingsModal
| ContentLanguagesSettingsModal
// Reporting // Reporting
| ReportAccountModal | ReportAccountModal

View File

@ -21,7 +21,7 @@ export function Component({}: {}) {
}, [store]) }, [store])
return ( return (
<View testID="reportPostModal" style={[pal.view, styles.container]}> <View testID="contentModerationModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Moderation</Text> <Text style={[pal.text, styles.title]}>Content Moderation</Text>
<ScrollView style={styles.scrollContainer}> <ScrollView style={styles.scrollContainer}>
<ContentLabelPref group="nsfw" /> <ContentLabelPref group="nsfw" />

View File

@ -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 (
<View testID="contentLanguagesModal" style={[pal.view, styles.container]}>
<Text style={[pal.text, styles.title]}>Content Languages</Text>
<Text style={[pal.text, styles.description]}>
Which languages would you like to see in the What's Hot feed? (Leave
them all unchecked to see any language.)
</Text>
<ScrollView style={styles.scrollContainer}>
{languages.map(lang => (
<LanguageToggle
key={lang.code2}
code2={lang.code2}
name={lang.name}
/>
))}
<View style={styles.bottomSpacer} />
</ScrollView>
<View style={[styles.btnContainer, pal.borderDark]}>
<Pressable
testID="sendReportBtn"
onPress={onPressDone}
accessibilityRole="button"
accessibilityLabel="Confirm content language settings"
accessibilityHint="">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>Done</Text>
</LinearGradient>
</Pressable>
</View>
</View>
)
}
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 (
<ToggleButton
label={name}
isSelected={store.preferences.contentLanguages.includes(code2)}
onPress={onPress}
style={[pal.border, styles.languageToggle]}
/>
)
},
)
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,
},
})

View File

@ -21,6 +21,7 @@ import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords' import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
const DEFAULT_SNAPPOINTS = ['90%'] const DEFAULT_SNAPPOINTS = ['90%']
@ -93,6 +94,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'content-filtering-settings') { } else if (activeModal?.name === 'content-filtering-settings') {
snapPoints = ContentFilteringSettingsModal.snapPoints snapPoints = ContentFilteringSettingsModal.snapPoints
element = <ContentFilteringSettingsModal.Component /> element = <ContentFilteringSettingsModal.Component />
} else if (activeModal?.name === 'content-languages-settings') {
snapPoints = ContentLanguagesSettingsModal.snapPoints
element = <ContentLanguagesSettingsModal.Component />
} else { } else {
return null return null
} }

View File

@ -21,6 +21,7 @@ import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
import * as AddAppPassword from './AddAppPasswords' import * as AddAppPassword from './AddAppPasswords'
import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings'
export const ModalsContainer = observer(function ModalsContainer() { export const ModalsContainer = observer(function ModalsContainer() {
const store = useStores() const store = useStores()
@ -84,6 +85,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <AddAppPassword.Component /> element = <AddAppPassword.Component />
} else if (modal.name === 'content-filtering-settings') { } else if (modal.name === 'content-filtering-settings') {
element = <ContentFilteringSettingsModal.Component /> element = <ContentFilteringSettingsModal.Component />
} else if (modal.name === 'content-languages-settings') {
element = <ContentLanguagesSettingsModal.Component />
} else if (modal.name === 'alt-text-image') { } else if (modal.name === 'alt-text-image') {
element = <AltTextImageModal.Component {...modal} /> element = <AltTextImageModal.Component {...modal} />
} else if (modal.name === 'alt-text-image-read') { } else if (modal.name === 'alt-text-image-read') {

View File

@ -48,7 +48,6 @@ export function FollowingEmptyState() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
emptyContainer: { emptyContainer: {
// flex: 1,
height: '100%', height: '100%',
paddingVertical: 40, paddingVertical: 40,
paddingHorizontal: 30, paddingHorizontal: 30,

View File

@ -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 (
<View style={styles.emptyContainer}>
<View style={styles.emptyIconContainer}>
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your What's Hot feed is empty! This is because there aren't enough users
posting in your selected language.
</Text>
<Button type="inverted" style={styles.emptyBtn} onPress={onPressSettings}>
<Text type="lg-medium" style={palInverted.text}>
Update my settings
</Text>
<FontAwesomeIcon
icon="angle-right"
style={palInverted.text as FontAwesomeIconStyle}
size={14}
/>
</Button>
</View>
)
}
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,
},
})

View File

@ -9,6 +9,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect'
import {Feed} from '../com/posts/Feed' import {Feed} from '../com/posts/Feed'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState'
import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {FeedsTabBar} from '../com/pager/FeedsTabBar'
import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager'
@ -24,16 +25,27 @@ const HEADER_OFFSET = isDesktopWeb ? 50 : 40
const POLL_FREQ = 30e3 // 30sec const POLL_FREQ = 30e3 // 30sec
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
export const HomeScreen = withAuthRequired((_opts: Props) => { export const HomeScreen = withAuthRequired(
observer((_opts: Props) => {
const store = useStores() const store = useStores()
const [selectedPage, setSelectedPage] = React.useState(0) const [selectedPage, setSelectedPage] = React.useState(0)
const [initialLanguages] = React.useState(
store.preferences.contentLanguages,
)
const algoFeed = React.useMemo(() => { const algoFeed: PostsFeedModel = React.useMemo(() => {
const feed = new PostsFeedModel(store, 'goodstuff', {}) const feed = new PostsFeedModel(store, 'goodstuff', {})
feed.setup() feed.setup()
return feed return feed
}, [store]) }, [store])
React.useEffect(() => {
// refresh whats hot when lang preferences change
if (initialLanguages !== store.preferences.contentLanguages) {
algoFeed.refresh()
}
}, [initialLanguages, store.preferences.contentLanguages, algoFeed])
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
store.shell.setMinimalShellMode(false) store.shell.setMinimalShellMode(false)
@ -74,6 +86,10 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
return <FollowingEmptyState /> return <FollowingEmptyState />
}, []) }, [])
const renderWhatsHotEmptyState = React.useCallback(() => {
return <WhatsHotEmptyState />
}, [])
const initialPage = store.me.followsCount === 0 ? 1 : 0 const initialPage = store.me.followsCount === 0 ? 1 : 0
return ( return (
<Pager <Pager
@ -94,10 +110,12 @@ export const HomeScreen = withAuthRequired((_opts: Props) => {
testID="whatshotFeedPage" testID="whatshotFeedPage"
isPageFocused={selectedPage === 1} isPageFocused={selectedPage === 1}
feed={algoFeed} feed={algoFeed}
renderEmptyState={renderWhatsHotEmptyState}
/> />
</Pager> </Pager>
) )
}) }),
)
const FeedPage = observer( const FeedPage = observer(
({ ({

View File

@ -131,6 +131,11 @@ export const SettingsScreen = withAuthRequired(
store.shell.openModal({name: 'content-filtering-settings'}) store.shell.openModal({name: 'content-filtering-settings'})
}, [track, store]) }, [track, store])
const onPressContentLanguages = React.useCallback(() => {
track('Settings:ContentlanguagesButtonClicked')
store.shell.openModal({name: 'content-languages-settings'})
}, [track, store])
const onPressSignout = React.useCallback(() => { const onPressSignout = React.useCallback(() => {
track('Settings:SignOutButtonClicked') track('Settings:SignOutButtonClicked')
store.session.logout() store.session.logout()
@ -312,9 +317,26 @@ export const SettingsScreen = withAuthRequired(
/> />
</View> </View>
<Text type="lg" style={pal.text}> <Text type="lg" style={pal.text}>
App Passwords App passwords
</Text> </Text>
</Link> </Link>
<TouchableOpacity
testID="contentLanguagesBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
onPress={isSwitching ? undefined : onPressContentLanguages}
accessibilityRole="button"
accessibilityHint="Content languages"
accessibilityLabel="Opens configurable content language settings">
<View style={[styles.iconContainer, pal.btn]}>
<FontAwesomeIcon
icon="language"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
<Text type="lg" style={pal.text}>
Content languages
</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
testID="changeHandleBtn" testID="changeHandleBtn"
style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}