diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/api/feed-manip.ts | 69 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 14 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 48 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 5 | ||||
-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/modals/PreferencesHomeFeed.tsx | 154 | ||||
-rw-r--r-- | src/view/screens/Settings.tsx | 23 |
8 files changed, 304 insertions, 16 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 3ff156dd6..c39667765 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -1,4 +1,9 @@ -import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyEmbedRecordWithMedia, + AppBskyEmbedRecord, +} from '@atproto/api' import lande from 'lande' import {hasProp} from 'lib/type-guards' import {LANGUAGES_MAP_CODE2} from '../../locale/languages' @@ -156,6 +161,38 @@ export class FeedTuner { return slices } + static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { + for (let i = slices.length - 1; i >= 0; i--) { + if (slices[i].isReply) { + slices.splice(i, 1) + } + } + return slices + } + + static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { + for (let i = slices.length - 1; i >= 0; i--) { + const reason = slices[i].rootItem.reason + if (AppBskyFeedDefs.isReasonRepost(reason)) { + slices.splice(i, 1) + } + } + return slices + } + + static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) { + for (let i = slices.length - 1; i >= 0; i--) { + const embed = slices[i].rootItem.post.embed + if ( + AppBskyEmbedRecord.isView(embed) || + AppBskyEmbedRecordWithMedia.isView(embed) + ) { + slices.splice(i, 1) + } + } + return slices + } + static dedupReposts( tuner: FeedTuner, slices: FeedViewPostsSlice[], @@ -178,23 +215,25 @@ export class FeedTuner { return slices } - static likedRepliesOnly( - tuner: FeedTuner, - slices: FeedViewPostsSlice[], - ): FeedViewPostsSlice[] { - // remove any replies without at least 2 likes - for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isFullThread || !slices[i].isReply) { - continue - } + static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) { + return ( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + ): FeedViewPostsSlice[] => { + // remove any replies without at least 2 likes + for (let i = slices.length - 1; i >= 0; i--) { + if (slices[i].isFullThread || !slices[i].isReply) { + continue + } - const item = slices[i].rootItem - const isRepost = Boolean(item.reason) - if (!isRepost && (item.post.likeCount || 0) < 2) { - slices.splice(i, 1) + const item = slices[i].rootItem + const isRepost = Boolean(item.reason) + if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) { + slices.splice(i, 1) + } } + return slices } - return slices } static preferredLangOnly(langsCode2: string[]) { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index b7d4def13..594143bf2 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -115,6 +115,12 @@ export class PostsFeedModel { } get feedTuners() { + const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled + const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold + const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled + const areQuotePostsEnabled = + this.rootStore.preferences.homeFeedQuotePostsEnabled + if (this.feedType === 'custom') { return [ FeedTuner.dedupReposts, @@ -124,7 +130,13 @@ export class PostsFeedModel { ] } if (this.feedType === 'home') { - return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] + return [ + areRepostsEnabled && FeedTuner.dedupReposts, + !areRepostsEnabled && FeedTuner.removeReposts, + areRepliesEnabled && FeedTuner.likedRepliesOnly({repliesThreshold}), + !areRepliesEnabled && FeedTuner.removeReplies, + !areQuotePostsEnabled && FeedTuner.removeQuotePosts, + ].filter(Boolean) } return [] } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index a42f0a837..6c9dc756e 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -51,6 +51,10 @@ export class PreferencesModel { contentLabels = new LabelPreferencesModel() savedFeeds: string[] = [] pinnedFeeds: string[] = [] + homeFeedRepliesEnabled: boolean = true + homeFeedRepliesThreshold: number = 2 + homeFeedRepostsEnabled: boolean = true + homeFeedQuotePostsEnabled: boolean = true // used to linearize async modifications to state lock = new AwaitLock() @@ -65,6 +69,10 @@ export class PreferencesModel { contentLabels: this.contentLabels, savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, + homeFeedRepliesEnabled: this.homeFeedRepliesEnabled, + homeFeedRepliesThreshold: this.homeFeedRepliesThreshold, + homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, + homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, } } @@ -102,6 +110,30 @@ export class PreferencesModel { ) { this.pinnedFeeds = v.pinnedFeeds } + if ( + hasProp(v, 'homeFeedRepliesEnabled') && + typeof v.homeFeedRepliesEnabled === 'boolean' + ) { + this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled + } + if ( + hasProp(v, 'homeFeedRepliesThreshold') && + typeof v.homeFeedRepliesThreshold === 'number' + ) { + this.homeFeedRepliesThreshold = v.homeFeedRepliesThreshold + } + if ( + hasProp(v, 'homeFeedRepostsEnabled') && + typeof v.homeFeedRepostsEnabled === 'boolean' + ) { + this.homeFeedRepostsEnabled = v.homeFeedRepostsEnabled + } + if ( + hasProp(v, 'homeFeedQuotePostsEnabled') && + typeof v.homeFeedQuotePostsEnabled === 'boolean' + ) { + this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled + } } } @@ -380,4 +412,20 @@ export class PreferencesModel { this.pinnedFeeds.filter(uri => uri !== v), ) } + + toggleHomeFeedRepliesEnabled() { + this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled + } + + setHomeFeedRepliesThreshold(threshold: number) { + this.homeFeedRepliesThreshold = threshold + } + + toggleHomeFeedRepostsEnabled() { + this.homeFeedRepostsEnabled = !this.homeFeedRepostsEnabled + } + + toggleHomeFeedQuotePostsEnabled() { + this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled + } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 3853f2395..c7e72e695 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -111,6 +111,10 @@ export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } +export interface PreferencesHomeFeed { + name: 'preferences-home-feed' +} + export type Modal = // Account | AddAppPasswordModal @@ -121,6 +125,7 @@ export type Modal = // Curation | ContentFilteringSettingsModal | ContentLanguagesSettingsModal + | PreferencesHomeFeed // Moderation | ReportAccountModal diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 864dcc847..5989d9ff9 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -24,6 +24,7 @@ import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' +import * as PreferencesHomeFeed from './PreferencesHomeFeed' const DEFAULT_SNAPPOINTS = ['90%'] @@ -105,6 +106,9 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = <ContentLanguagesSettingsModal.Component /> + } else if (activeModal?.name === 'preferences-home-feed') { + snapPoints = PreferencesHomeFeed.snapPoints + element = <PreferencesHomeFeed.Component /> } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 27b2641ba..3895d47ac 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -24,6 +24,7 @@ import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './ContentLanguagesSettings' +import * as PreferencesHomeFeed from './PreferencesHomeFeed' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -97,6 +98,8 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> + } else if (modal.name === 'preferences-home-feed') { + element = <PreferencesHomeFeed.Component /> } else { return null } diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/com/modals/PreferencesHomeFeed.tsx new file mode 100644 index 000000000..0950b6366 --- /dev/null +++ b/src/view/com/modals/PreferencesHomeFeed.tsx @@ -0,0 +1,154 @@ +import React, {useState} from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {Slider} from '@miblanchard/react-native-slider' + +import {Text} from '../util/text/Text' +import {useStores} from 'state/index' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isDesktopWeb} from 'platform/detection' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {ScrollView} from 'view/com/modals/util' + +export const snapPoints = ['90%'] + +function RepliesThresholdInput({enabled}: {enabled: boolean}) { + const store = useStores() + const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) + + return ( + <View + style={{ + marginTop: 10, + opacity: enabled ? 1 : 0.3, + }}> + <Text> + {value === 0 + ? `Show all replies` + : `Show replies with greater than ${value} likes`} + </Text> + <Slider + value={value} + onValueChange={(v: number | number[]) => { + const threshold = Math.floor(Array.isArray(v) ? v[0] : v) + setValue(threshold) + store.preferences.setHomeFeedRepliesThreshold(threshold) + }} + minimumValue={0} + maximumValue={25} + containerStyle={{flex: 1}} + disabled={!enabled} + thumbTintColor={colors.blue3} + /> + </View> + ) +} + +export const Component = observer(function Component() { + const pal = usePalette('default') + const store = useStores() + + return ( + <View + testID="preferencesHomeFeedModal" + style={[pal.view, styles.container]}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Home Feed Preferences + </Text> + + <ScrollView> + <View style={[styles.card]}> + <Text type="title-sm" style={[s.pb5]}> + Show Replies + </Text> + <Text style={[s.pb10]}> + Replies are shown in your home feed by default. If this setting is + disabled, you'll see only new posts and threads. + </Text> + <ToggleButton + type="default-light" + label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.homeFeedRepliesEnabled} + onPress={store.preferences.toggleHomeFeedRepliesEnabled} + /> + + <RepliesThresholdInput + enabled={store.preferences.homeFeedRepliesEnabled} + /> + </View> + + <View style={[styles.card]}> + <Text type="title-sm" style={[s.pb5]}> + Show Reposts + </Text> + <Text style={[s.pb10]}>Description</Text> + <ToggleButton + type="default-light" + label={store.preferences.homeFeedRepostsEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.homeFeedRepostsEnabled} + onPress={store.preferences.toggleHomeFeedRepostsEnabled} + /> + </View> + + <View style={[styles.card]}> + <Text type="title-sm" style={[s.pb5]}> + Show Quote Posts + </Text> + <Text style={[s.pb10]}>Description</Text> + <ToggleButton + type="default-light" + label={store.preferences.homeFeedQuotePostsEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.homeFeedQuotePostsEnabled} + onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} + /> + </View> + </ScrollView> + + <View style={[styles.btnContainer, pal.borderDark]}> + <TouchableOpacity + testID="confirmBtn" + onPress={() => { + store.shell.closeModal() + }} + style={[styles.btn]} + accessibilityRole="button" + accessibilityLabel="Confirm" + accessibilityHint=""> + <Text style={[s.white, s.bold, s.f18]}>Done</Text> + </TouchableOpacity> + </View> + </View> + ) +}) + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + paddingBottom: isDesktopWeb ? 0 : 60, + }, + title: { + textAlign: 'center', + marginBottom: 20, + }, + card: { + ...s.p20, + backgroundColor: s.gray1.color, + borderRadius: 10, + marginBottom: 20, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 10, + paddingHorizontal: 10, + borderTopWidth: isDesktopWeb ? 0 : 1, + }, +}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3d057451a..b145741fe 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -166,6 +166,12 @@ export const SettingsScreen = withAuthRequired( Toast.show('Copied build version to clipboard') }, []) + const openPreferencesModal = React.useCallback(() => { + store.shell.openModal({ + name: 'preferences-home-feed', + }) + }, [store]) + return ( <View style={[s.hContentRegion]} testID="settingsScreen"> <ViewHeader title="Settings" /> @@ -377,6 +383,23 @@ export const SettingsScreen = withAuthRequired( </Text> </Link> <TouchableOpacity + testID="preferencesHomeFeedModalButton" + style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} + onPress={openPreferencesModal} + accessibilityRole="button" + accessibilityHint="Open home feed preferences modal" + accessibilityLabel="Opens the home feed preferences modal"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="language" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + Preferences + </Text> + </TouchableOpacity> + <TouchableOpacity testID="contentLanguagesBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} onPress={isSwitching ? undefined : onPressContentLanguages} |