[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 rolezio/stable
parent
95f8360d19
commit
6f1c4ec9a9
|
@ -0,0 +1 @@
|
||||||
|
export const getLocales = jest.fn().mockResolvedValue([])
|
|
@ -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,11 +238,7 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -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,80 +25,97 @@ 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(
|
||||||
const store = useStores()
|
observer((_opts: Props) => {
|
||||||
const [selectedPage, setSelectedPage] = React.useState(0)
|
const store = useStores()
|
||||||
|
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])
|
||||||
|
|
||||||
useFocusEffect(
|
React.useEffect(() => {
|
||||||
React.useCallback(() => {
|
// refresh whats hot when lang preferences change
|
||||||
store.shell.setMinimalShellMode(false)
|
if (initialLanguages !== store.preferences.contentLanguages) {
|
||||||
store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
|
algoFeed.refresh()
|
||||||
return () => {
|
|
||||||
store.shell.setIsDrawerSwipeDisabled(false)
|
|
||||||
}
|
}
|
||||||
}, [store, selectedPage]),
|
}, [initialLanguages, store.preferences.contentLanguages, algoFeed])
|
||||||
)
|
|
||||||
|
|
||||||
const onPageSelected = React.useCallback(
|
useFocusEffect(
|
||||||
(index: number) => {
|
React.useCallback(() => {
|
||||||
store.shell.setMinimalShellMode(false)
|
store.shell.setMinimalShellMode(false)
|
||||||
setSelectedPage(index)
|
store.shell.setIsDrawerSwipeDisabled(selectedPage > 0)
|
||||||
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
return () => {
|
||||||
},
|
store.shell.setIsDrawerSwipeDisabled(false)
|
||||||
[store],
|
}
|
||||||
)
|
}, [store, selectedPage]),
|
||||||
|
)
|
||||||
|
|
||||||
const onPressSelected = React.useCallback(() => {
|
const onPageSelected = React.useCallback(
|
||||||
store.emitScreenSoftReset()
|
(index: number) => {
|
||||||
}, [store])
|
store.shell.setMinimalShellMode(false)
|
||||||
|
setSelectedPage(index)
|
||||||
|
store.shell.setIsDrawerSwipeDisabled(index > 0)
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
)
|
||||||
|
|
||||||
const renderTabBar = React.useCallback(
|
const onPressSelected = React.useCallback(() => {
|
||||||
(props: RenderTabBarFnProps) => {
|
store.emitScreenSoftReset()
|
||||||
return (
|
}, [store])
|
||||||
<FeedsTabBar
|
|
||||||
{...props}
|
const renderTabBar = React.useCallback(
|
||||||
testID="homeScreenFeedTabs"
|
(props: RenderTabBarFnProps) => {
|
||||||
onPressSelected={onPressSelected}
|
return (
|
||||||
|
<FeedsTabBar
|
||||||
|
{...props}
|
||||||
|
testID="homeScreenFeedTabs"
|
||||||
|
onPressSelected={onPressSelected}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[onPressSelected],
|
||||||
|
)
|
||||||
|
|
||||||
|
const renderFollowingEmptyState = React.useCallback(() => {
|
||||||
|
return <FollowingEmptyState />
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderWhatsHotEmptyState = React.useCallback(() => {
|
||||||
|
return <WhatsHotEmptyState />
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initialPage = store.me.followsCount === 0 ? 1 : 0
|
||||||
|
return (
|
||||||
|
<Pager
|
||||||
|
testID="homeScreen"
|
||||||
|
onPageSelected={onPageSelected}
|
||||||
|
renderTabBar={renderTabBar}
|
||||||
|
tabBarPosition="top"
|
||||||
|
initialPage={initialPage}>
|
||||||
|
<FeedPage
|
||||||
|
key="1"
|
||||||
|
testID="followingFeedPage"
|
||||||
|
isPageFocused={selectedPage === 0}
|
||||||
|
feed={store.me.mainFeed}
|
||||||
|
renderEmptyState={renderFollowingEmptyState}
|
||||||
/>
|
/>
|
||||||
)
|
<FeedPage
|
||||||
},
|
key="2"
|
||||||
[onPressSelected],
|
testID="whatshotFeedPage"
|
||||||
)
|
isPageFocused={selectedPage === 1}
|
||||||
|
feed={algoFeed}
|
||||||
const renderFollowingEmptyState = React.useCallback(() => {
|
renderEmptyState={renderWhatsHotEmptyState}
|
||||||
return <FollowingEmptyState />
|
/>
|
||||||
}, [])
|
</Pager>
|
||||||
|
)
|
||||||
const initialPage = store.me.followsCount === 0 ? 1 : 0
|
}),
|
||||||
return (
|
)
|
||||||
<Pager
|
|
||||||
testID="homeScreen"
|
|
||||||
onPageSelected={onPageSelected}
|
|
||||||
renderTabBar={renderTabBar}
|
|
||||||
tabBarPosition="top"
|
|
||||||
initialPage={initialPage}>
|
|
||||||
<FeedPage
|
|
||||||
key="1"
|
|
||||||
testID="followingFeedPage"
|
|
||||||
isPageFocused={selectedPage === 0}
|
|
||||||
feed={store.me.mainFeed}
|
|
||||||
renderEmptyState={renderFollowingEmptyState}
|
|
||||||
/>
|
|
||||||
<FeedPage
|
|
||||||
key="2"
|
|
||||||
testID="whatshotFeedPage"
|
|
||||||
isPageFocused={selectedPage === 1}
|
|
||||||
feed={algoFeed}
|
|
||||||
/>
|
|
||||||
</Pager>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const FeedPage = observer(
|
const FeedPage = observer(
|
||||||
({
|
({
|
||||||
|
|
|
@ -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]}
|
||||||
|
|
Loading…
Reference in New Issue