diff --git a/package.json b/package.json index 67747791..61c20d2f 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:apk": "eas build -p android --profile dev-android-apk" }, "dependencies": { - "@atproto/api": "^0.6.14", + "@atproto/api": "^0.6.16", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index f9327826..31e27fec 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -109,7 +109,7 @@ export class MergeFeedAPI implements FeedAPI { } _captureFeedsIfNeeded() { - if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) { + if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) { return } if (this.customFeeds.length === 0) { diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 2d3a3d64..981fb1f1 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -8,6 +8,7 @@ import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' +import {ThreadViewPreference} from '../ui/preferences' import {PostThreadItemModel} from './post-thread-item' export class PostThreadModel { @@ -241,7 +242,7 @@ export class PostThreadModel { res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) - sortThread(thread, this.rootStore.preferences) + sortThread(thread, this.rootStore.preferences.thread) this.thread = thread } } @@ -263,16 +264,11 @@ function pruneReplies(post: MaybePost) { } } -interface SortSettings { - threadDefaultSort: string - threadFollowedUsersFirst: boolean -} - type MaybeThreadItem = | PostThreadItemModel | AppBskyFeedDefs.NotFoundPost | AppBskyFeedDefs.BlockedPost -function sortThread(item: MaybeThreadItem, opts: SortSettings) { +function sortThread(item: MaybeThreadItem, opts: ThreadViewPreference) { if ('notFound' in item) { return } @@ -301,7 +297,7 @@ function sortThread(item: MaybeThreadItem, opts: SortSettings) { if (modScore(a.moderation) !== modScore(b.moderation)) { return modScore(a.moderation) - modScore(b.moderation) } - if (opts.threadFollowedUsersFirst) { + if (opts.prioritizeFollowedUsers) { const af = a.post.author.viewer?.following const bf = b.post.author.viewer?.following if (af && !bf) { @@ -310,17 +306,17 @@ function sortThread(item: MaybeThreadItem, opts: SortSettings) { return 1 } } - if (opts.threadDefaultSort === 'oldest') { + if (opts.sort === 'oldest') { return a.post.indexedAt.localeCompare(b.post.indexedAt) - } else if (opts.threadDefaultSort === 'newest') { + } else if (opts.sort === 'newest') { return b.post.indexedAt.localeCompare(a.post.indexedAt) - } else if (opts.threadDefaultSort === 'most-likes') { + } else if (opts.sort === 'most-likes') { if (a.post.likeCount === b.post.likeCount) { return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest } else { return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes } - } else if (opts.threadDefaultSort === 'random') { + } else if (opts.sort === 'random') { return 0.5 - Math.random() // this is vaguely criminal but we can get away with it } return b.post.indexedAt.localeCompare(a.post.indexedAt) diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 5ae39167..b3365bd7 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,5 +1,9 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {LabelPreference as APILabelPreference} from '@atproto/api' +import { + LabelPreference as APILabelPreference, + BskyFeedViewPreference, + BskyThreadViewPreference, +} from '@atproto/api' import AwaitLock from 'await-lock' import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' @@ -13,6 +17,12 @@ import {LANGUAGES} from '../../../locale/languages' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf export type LabelPreference = APILabelPreference | 'show' +export type FeedViewPreference = BskyFeedViewPreference & { + lab_mergeFeedEnabled?: boolean | undefined +} +export type ThreadViewPreference = BskyThreadViewPreference & { + lab_treeViewEnabled?: boolean | undefined +} const LABEL_GROUPS = [ 'nsfw', 'nudity', @@ -28,6 +38,13 @@ const DEFAULT_LANG_CODES = (deviceLocales || []) .slice(0, 6) const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] +interface LegacyPreferences { + hideReplies?: boolean + hideRepliesByLikeCount?: number + hideReposts?: boolean + hideQuotePosts?: boolean +} + export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' nudity: LabelPreference = 'warn' @@ -52,17 +69,24 @@ export class PreferencesModel { savedFeeds: string[] = [] pinnedFeeds: string[] = [] birthDate: Date | undefined = undefined - homeFeedRepliesEnabled: boolean = true - homeFeedRepliesByFollowedOnlyEnabled: boolean = true - homeFeedRepliesThreshold: number = 0 - homeFeedRepostsEnabled: boolean = true - homeFeedQuotePostsEnabled: boolean = true - homeFeedMergeFeedEnabled: boolean = false - threadDefaultSort: string = 'oldest' - threadFollowedUsersFirst: boolean = true - threadTreeViewEnabled: boolean = false + homeFeed: FeedViewPreference = { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + lab_mergeFeedEnabled: false, // experimental + } + thread: ThreadViewPreference = { + sort: 'oldest', + prioritizeFollowedUsers: true, + lab_treeViewEnabled: false, // experimental + } requireAltTextEnabled: boolean = false + // used to help with transitions from device-stored to server-stored preferences + legacyPreferences: LegacyPreferences | undefined + // used to linearize async modifications to state lock = new AwaitLock() @@ -86,16 +110,6 @@ export class PreferencesModel { contentLabels: this.contentLabels, savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, - homeFeedRepliesEnabled: this.homeFeedRepliesEnabled, - homeFeedRepliesByFollowedOnlyEnabled: - this.homeFeedRepliesByFollowedOnlyEnabled, - homeFeedRepliesThreshold: this.homeFeedRepliesThreshold, - homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, - homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, - homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, - threadDefaultSort: this.threadDefaultSort, - threadFollowedUsersFirst: this.threadFollowedUsersFirst, - threadTreeViewEnabled: this.threadTreeViewEnabled, requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -165,71 +179,6 @@ export class PreferencesModel { ) { this.pinnedFeeds = v.pinnedFeeds } - // check if home feed replies are enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedRepliesEnabled') && - typeof v.homeFeedRepliesEnabled === 'boolean' - ) { - this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled - } - // check if home feed replies "followed only" are enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') && - typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean' - ) { - this.homeFeedRepliesByFollowedOnlyEnabled = - v.homeFeedRepliesByFollowedOnlyEnabled - } - // check if home feed replies threshold is enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedRepliesThreshold') && - typeof v.homeFeedRepliesThreshold === 'number' - ) { - this.homeFeedRepliesThreshold = v.homeFeedRepliesThreshold - } - // check if home feed reposts are enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedRepostsEnabled') && - typeof v.homeFeedRepostsEnabled === 'boolean' - ) { - this.homeFeedRepostsEnabled = v.homeFeedRepostsEnabled - } - // check if home feed quote posts are enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedQuotePostsEnabled') && - typeof v.homeFeedQuotePostsEnabled === 'boolean' - ) { - this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled - } - // check if home feed mergefeed is enabled in preferences, then hydrate - if ( - hasProp(v, 'homeFeedMergeFeedEnabled') && - typeof v.homeFeedMergeFeedEnabled === 'boolean' - ) { - this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled - } - // check if thread sort order is set in preferences, then hydrate - if ( - hasProp(v, 'threadDefaultSort') && - typeof v.threadDefaultSort === 'string' && - THREAD_SORT_VALUES.includes(v.threadDefaultSort) - ) { - this.threadDefaultSort = v.threadDefaultSort - } - // check if thread followed-users-first is enabled in preferences, then hydrate - if ( - hasProp(v, 'threadFollowedUsersFirst') && - typeof v.threadFollowedUsersFirst === 'boolean' - ) { - this.threadFollowedUsersFirst = v.threadFollowedUsersFirst - } - // check if thread treeview is enabled in preferences, then hydrate - if ( - hasProp(v, 'threadTreeViewEnabled') && - typeof v.threadTreeViewEnabled === 'boolean' - ) { - this.threadTreeViewEnabled = v.threadTreeViewEnabled - } // check if requiring alt text is enabled in preferences, then hydrate if ( hasProp(v, 'requireAltTextEnabled') && @@ -237,6 +186,8 @@ export class PreferencesModel { ) { this.requireAltTextEnabled = v.requireAltTextEnabled } + // grab legacy values + this.legacyPreferences = getLegacyPreferences(v) } } @@ -250,6 +201,10 @@ export class PreferencesModel { const prefs = await this.rootStore.agent.getPreferences() runInAction(() => { + if (prefs.feedViewPrefs.home) { + this.homeFeed = prefs.feedViewPrefs.home + } + this.thread = prefs.threadViewPrefs this.adultContentEnabled = prefs.adultContentEnabled for (const label in prefs.contentLabels) { if ( @@ -272,6 +227,9 @@ export class PreferencesModel { this.birthDate = prefs.birthDate }) + // sync legacy values if needed + await this.syncLegacyPreferences() + // set defaults on missing items if (typeof prefs.feeds.saved === 'undefined') { try { @@ -298,6 +256,14 @@ export class PreferencesModel { await this.rootStore.me.savedFeeds.updateCache(clearCache) } + async syncLegacyPreferences() { + if (this.legacyPreferences) { + this.homeFeed = {...this.homeFeed, ...this.legacyPreferences} + this.legacyPreferences = undefined + await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed) + } + } + /** * This function resets the preferences to an empty array of no preferences. */ @@ -510,43 +476,68 @@ export class PreferencesModel { await this.rootStore.agent.setPersonalDetails({birthDate}) } - toggleHomeFeedRepliesEnabled() { - this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled + async toggleHomeFeedHideReplies() { + this.homeFeed.hideReplies = !this.homeFeed.hideReplies + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReplies: this.homeFeed.hideReplies, + }) } - toggleHomeFeedRepliesByFollowedOnlyEnabled() { - this.homeFeedRepliesByFollowedOnlyEnabled = - !this.homeFeedRepliesByFollowedOnlyEnabled + async toggleHomeFeedHideRepliesByUnfollowed() { + this.homeFeed.hideRepliesByUnfollowed = + !this.homeFeed.hideRepliesByUnfollowed + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, + }) } - setHomeFeedRepliesThreshold(threshold: number) { - this.homeFeedRepliesThreshold = threshold + async setHomeFeedHideRepliesByLikeCount(threshold: number) { + this.homeFeed.hideRepliesByLikeCount = threshold + await this.rootStore.agent.setFeedViewPrefs('home', { + hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, + }) } - toggleHomeFeedRepostsEnabled() { - this.homeFeedRepostsEnabled = !this.homeFeedRepostsEnabled + async toggleHomeFeedHideReposts() { + this.homeFeed.hideReposts = !this.homeFeed.hideReposts + await this.rootStore.agent.setFeedViewPrefs('home', { + hideReposts: this.homeFeed.hideReposts, + }) } - toggleHomeFeedQuotePostsEnabled() { - this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled + async toggleHomeFeedHideQuotePosts() { + this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts + await this.rootStore.agent.setFeedViewPrefs('home', { + hideQuotePosts: this.homeFeed.hideQuotePosts, + }) } - toggleHomeFeedMergeFeedEnabled() { - this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled + async toggleHomeFeedMergeFeedEnabled() { + this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled + await this.rootStore.agent.setFeedViewPrefs('home', { + lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, + }) } - setThreadDefaultSort(v: string) { + async setThreadSort(v: string) { if (THREAD_SORT_VALUES.includes(v)) { - this.threadDefaultSort = v + this.thread.sort = v + await this.rootStore.agent.setThreadViewPrefs({sort: v}) } } - toggleThreadFollowedUsersFirst() { - this.threadFollowedUsersFirst = !this.threadFollowedUsersFirst + async togglePrioritizedFollowedUsers() { + this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers + await this.rootStore.agent.setThreadViewPrefs({ + prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, + }) } - toggleThreadTreeViewEnabled() { - this.threadTreeViewEnabled = !this.threadTreeViewEnabled + async toggleThreadTreeViewEnabled() { + this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled + await this.rootStore.agent.setThreadViewPrefs({ + lab_treeViewEnabled: this.thread.lab_treeViewEnabled, + }) } toggleRequireAltTextEnabled() { @@ -560,13 +551,6 @@ export class PreferencesModel { getFeedTuners( feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', ) { - const areRepliesEnabled = this.homeFeedRepliesEnabled - const areRepliesByFollowedOnlyEnabled = - this.homeFeedRepliesByFollowedOnlyEnabled - const repliesThreshold = this.homeFeedRepliesThreshold - const areRepostsEnabled = this.homeFeedRepostsEnabled - const areQuotePostsEnabled = this.homeFeedQuotePostsEnabled - if (feedType === 'custom') { return [ FeedTuner.dedupReposts, @@ -576,25 +560,25 @@ export class PreferencesModel { if (feedType === 'home' || feedType === 'following') { const feedTuners = [] - if (areRepostsEnabled) { - feedTuners.push(FeedTuner.dedupReposts) - } else { + if (this.homeFeed.hideReposts) { feedTuners.push(FeedTuner.removeReposts) + } else { + feedTuners.push(FeedTuner.dedupReposts) } - if (areRepliesEnabled) { + if (this.homeFeed.hideReplies) { + feedTuners.push(FeedTuner.removeReplies) + } else { feedTuners.push( FeedTuner.thresholdRepliesOnly({ userDid: this.rootStore.session.data?.did || '', - minLikes: repliesThreshold, - followedOnly: areRepliesByFollowedOnlyEnabled, + minLikes: this.homeFeed.hideRepliesByLikeCount, + followedOnly: !!this.homeFeed.hideRepliesByUnfollowed, }), ) - } else { - feedTuners.push(FeedTuner.removeReplies) } - if (!areQuotePostsEnabled) { + if (this.homeFeed.hideQuotePosts) { feedTuners.push(FeedTuner.removeQuotePosts) } @@ -611,3 +595,37 @@ function tempfixLabelPref(pref: LabelPreference): APILabelPreference { } return pref } + +function getLegacyPreferences( + v: Record, +): LegacyPreferences | undefined { + const legacyPreferences: LegacyPreferences = {} + if ( + hasProp(v, 'homeFeedRepliesEnabled') && + typeof v.homeFeedRepliesEnabled === 'boolean' + ) { + legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled + } + if ( + hasProp(v, 'homeFeedRepliesThreshold') && + typeof v.homeFeedRepliesThreshold === 'number' + ) { + legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold + } + if ( + hasProp(v, 'homeFeedRepostsEnabled') && + typeof v.homeFeedRepostsEnabled === 'boolean' + ) { + legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled + } + if ( + hasProp(v, 'homeFeedQuotePostsEnabled') && + typeof v.homeFeedQuotePostsEnabled === 'boolean' + ) { + legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled + } + if (Object.keys(legacyPreferences).length) { + return legacyPreferences + } + return undefined +} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 630a4b6b..d4447f13 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -76,7 +76,7 @@ export const PostThreadScreen = withAuthRequired( uri={uri} view={view} onPressReply={onPressReply} - treeView={store.preferences.threadTreeViewEnabled} + treeView={!!store.preferences.thread.lab_treeViewEnabled} /> {isMobile && !store.shell.minimalShellMode && ( diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 8f6e0761..21c15931 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -13,11 +13,23 @@ import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import debounce from 'lodash.debounce' function RepliesThresholdInput({enabled}: {enabled: boolean}) { const store = useStores() const pal = usePalette('default') - const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) + const [value, setValue] = useState( + store.preferences.homeFeed.hideRepliesByLikeCount, + ) + const save = React.useMemo( + () => + debounce( + threshold => + store.preferences.setHomeFeedHideRepliesByLikeCount(threshold), + 500, + ), // debouce for 500ms + [store], + ) return ( @@ -26,7 +38,7 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { onValueChange={(v: number | number[]) => { const threshold = Math.floor(Array.isArray(v) ? v[0] : v) setValue(threshold) - store.preferences.setHomeFeedRepliesThreshold(threshold) + save(threshold) }} minimumValue={0} maximumValue={25} @@ -88,16 +100,16 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ Reply Filters @@ -108,12 +120,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ @@ -136,9 +146,9 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ @@ -152,9 +162,9 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ @@ -169,8 +179,10 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index 74b28267..af98a183 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -59,8 +59,8 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ {key: 'most-likes', label: 'Most-liked replies first'}, {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, ]} - onSelect={store.preferences.setThreadDefaultSort} - initialSelection={store.preferences.threadDefaultSort} + onSelect={store.preferences.setThreadSort} + initialSelection={store.preferences.thread.sort} /> @@ -74,9 +74,11 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ @@ -91,8 +93,10 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ diff --git a/yarn.lock b/yarn.lock index 449d92db..2bd08edb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -47,6 +47,19 @@ tlds "^1.234.0" typed-emitter "^2.1.0" +"@atproto/api@^0.6.16": + version "0.6.16" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.16.tgz#0e5f259a8eb8af239b4e77bf70d7e770b33f4eeb" + integrity sha512-DpG994bdwk7NWJSb36Af+0+FRWMFZgzTcrK0rN2tvlsMh6wBF/RdErjHKuoL8wcogGzbI2yp8eOqsA00lyoisw== + dependencies: + "@atproto/common-web" "^0.2.0" + "@atproto/lexicon" "^0.2.1" + "@atproto/syntax" "^0.1.1" + "@atproto/xrpc" "^0.3.1" + multiformats "^9.9.0" + tlds "^1.234.0" + typed-emitter "^2.1.0" + "@atproto/bsky@^0.0.5": version "0.0.5" resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.5.tgz#4667977158a112f27aeab14fedb3ca1e3ebbd873"