From 896aea98374188ac33ac92e1ed582d5d7f189ac6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 16 Jun 2023 14:59:29 -0500 Subject: [PATCH] feat: add home feed preferences settings modal and tuners --- src/lib/api/feed-manip.ts | 69 +++++++-- src/state/models/feeds/posts.ts | 14 +- src/state/models/ui/preferences.ts | 48 ++++++ src/state/models/ui/shell.ts | 5 + src/view/com/modals/Modal.tsx | 4 + src/view/com/modals/Modal.web.tsx | 3 + src/view/com/modals/PreferencesHomeFeed.tsx | 154 ++++++++++++++++++++ src/view/screens/Settings.tsx | 23 +++ 8 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 src/view/com/modals/PreferencesHomeFeed.tsx diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 3ff156dd..c3966776 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 b7d4def1..594143bf 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 a42f0a83..6c9dc756 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 3853f239..c7e72e69 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 864dcc84..5989d9ff 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 = + } else if (activeModal?.name === 'preferences-home-feed') { + snapPoints = PreferencesHomeFeed.snapPoints + element = } else { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 27b2641b..3895d47a 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 = } else if (modal.name === 'edit-image') { element = + } else if (modal.name === 'preferences-home-feed') { + element = } else { return null } diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/com/modals/PreferencesHomeFeed.tsx new file mode 100644 index 00000000..0950b636 --- /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 ( + + + {value === 0 + ? `Show all replies` + : `Show replies with greater than ${value} likes`} + + { + 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} + /> + + ) +} + +export const Component = observer(function Component() { + const pal = usePalette('default') + const store = useStores() + + return ( + + + Home Feed Preferences + + + + + + Show Replies + + + Replies are shown in your home feed by default. If this setting is + disabled, you'll see only new posts and threads. + + + + + + + + + Show Reposts + + Description + + + + + + Show Quote Posts + + Description + + + + + + { + store.shell.closeModal() + }} + style={[styles.btn]} + accessibilityRole="button" + accessibilityLabel="Confirm" + accessibilityHint=""> + Done + + + + ) +}) + +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 3d057451..b145741f 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 ( @@ -376,6 +382,23 @@ export const SettingsScreen = withAuthRequired( Saved Feeds + + + + + + Preferences + +