diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/api/feed-manip.ts | 10 | ||||
-rw-r--r-- | src/locale/languages.ts | 2 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 12 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 39 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 5 | ||||
-rw-r--r-- | src/view/com/modals/ContentFilteringSettings.tsx | 2 | ||||
-rw-r--r-- | src/view/com/modals/ContentLanguagesSettings.tsx | 143 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/posts/FollowingEmptyState.tsx | 1 | ||||
-rw-r--r-- | src/view/com/posts/WhatsHotEmptyState.tsx | 76 | ||||
-rw-r--r-- | src/view/screens/Home.tsx | 162 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 24 |
13 files changed, 385 insertions, 98 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 60e755048..96534d1ba 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 31c1a9f70..269e2fa9a 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 62047acba..44cec3af7 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 ae3f712c4..f6b29169d 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 0b0da0001..dea220c55 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 c683e43f8..cfba2575a 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -21,7 +21,7 @@ export function Component({}: {}) { }, [store]) 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> <ScrollView style={styles.scrollContainer}> <ContentLabelPref group="nsfw" /> diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx new file mode 100644 index 000000000..0c750fe0e --- /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 ( + <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, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2e053e3ad..b5d71a116 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 = <ContentFilteringSettingsModal.Component /> + } else if (activeModal?.name === 'content-languages-settings') { + snapPoints = ContentLanguagesSettingsModal.snapPoints + element = <ContentLanguagesSettingsModal.Component /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index e850c9f21..50487e3eb 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 = <AddAppPassword.Component /> } else if (modal.name === 'content-filtering-settings') { element = <ContentFilteringSettingsModal.Component /> + } else if (modal.name === 'content-languages-settings') { + element = <ContentLanguagesSettingsModal.Component /> } else if (modal.name === 'alt-text-image') { element = <AltTextImageModal.Component {...modal} /> } 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 acd035f21..b37298179 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 000000000..ade94ca3f --- /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 ( + <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, + }, +}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index ba9b05c43..2b102ae31 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<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired((_opts: Props) => { - const store = useStores() - const [selectedPage, setSelectedPage] = React.useState(0) - - const algoFeed = 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) +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: PostsFeedModel = React.useMemo(() => { + const feed = new PostsFeedModel(store, 'goodstuff', {}) + feed.setup() + return feed + }, [store]) + + React.useEffect(() => { + // refresh whats hot when lang preferences change + if (initialLanguages !== store.preferences.contentLanguages) { + algoFeed.refresh() } - }, [store, selectedPage]), - ) - - const onPageSelected = React.useCallback( - (index: number) => { - store.shell.setMinimalShellMode(false) - setSelectedPage(index) - store.shell.setIsDrawerSwipeDisabled(index > 0) - }, - [store], - ) - - const onPressSelected = React.useCallback(() => { - store.emitScreenSoftReset() - }, [store]) - - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { - return ( - <FeedsTabBar - {...props} - testID="homeScreenFeedTabs" - onPressSelected={onPressSelected} + }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + store.shell.setIsDrawerSwipeDisabled(selectedPage > 0) + return () => { + store.shell.setIsDrawerSwipeDisabled(false) + } + }, [store, selectedPage]), + ) + + const onPageSelected = React.useCallback( + (index: number) => { + store.shell.setMinimalShellMode(false) + setSelectedPage(index) + store.shell.setIsDrawerSwipeDisabled(index > 0) + }, + [store], + ) + + const onPressSelected = React.useCallback(() => { + store.emitScreenSoftReset() + }, [store]) + + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + 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} /> - ) - }, - [onPressSelected], - ) - - const renderFollowingEmptyState = React.useCallback(() => { - return <FollowingEmptyState /> - }, []) - - 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> - ) -}) + <FeedPage + key="2" + testID="whatshotFeedPage" + isPageFocused={selectedPage === 1} + feed={algoFeed} + renderEmptyState={renderWhatsHotEmptyState} + /> + </Pager> + ) + }), +) const FeedPage = observer( ({ diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 705c37b30..7c48ce96b 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,10 +317,27 @@ export const SettingsScreen = withAuthRequired( /> </View> <Text type="lg" style={pal.text}> - App Passwords + App passwords </Text> </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 testID="changeHandleBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} onPress={isSwitching ? undefined : onPressChangeHandle} |