Eric/preferences (#1873)

* Add initial preferences query, couple mutations

* Remove unused

* Clean up labels, migrate getModerationOpts

* Add birth date handling

* Migrate feed prefs

* Migrate thread view prefs

* Migrate homeFeed to use existing key name

* Fix up saved feeds in response, no impl yet

* Migrate saved feeds to new hooks

* Clean up more of preferences

* Fix PreferencesThreads load state

* Fix modal dismissal

* Small spacing fix

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
Eric Bailey 2023-11-12 13:31:11 -06:00 committed by GitHub
parent c8c308e31e
commit 05b728fffc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1339 additions and 914 deletions

View file

@ -1,19 +1,13 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {makeAutoObservable} from 'mobx'
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'
import {RootStoreModel} from '../root-store'
import {ModerationOpts} from '@atproto/api'
import {DEFAULT_FEEDS} from 'lib/constants'
import {getAge} from 'lib/strings/time'
import {FeedTuner} from 'lib/api/feed-manip'
import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
export type LabelPreference = APILabelPreference | 'show'
@ -23,24 +17,6 @@ export type FeedViewPreference = BskyFeedViewPreference & {
export type ThreadViewPreference = BskyThreadViewPreference & {
lab_treeViewEnabled?: boolean | undefined
}
const LABEL_GROUPS = [
'nsfw',
'nudity',
'suggestive',
'gore',
'hate',
'spam',
'impersonation',
]
const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
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'
@ -76,9 +52,6 @@ export class PreferencesModel {
lab_treeViewEnabled: false, // experimental
}
// 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,13 +59,6 @@ export class PreferencesModel {
makeAutoObservable(this, {lock: false}, {autoBind: true})
}
get userAge(): number | undefined {
if (!this.birthDate) {
return undefined
}
return getAge(this.birthDate)
}
serialize() {
return {
contentLabels: this.contentLabels,
@ -128,117 +94,15 @@ export class PreferencesModel {
) {
this.pinnedFeeds = v.pinnedFeeds
}
// grab legacy values
this.legacyPreferences = getLegacyPreferences(v)
}
}
/**
* This function fetches preferences and sets defaults for missing items.
*/
async sync() {
await this.lock.acquireAsync()
try {
// fetch preferences
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 (
LABEL_GROUPS.includes(label) &&
VISIBILITY_VALUES.includes(prefs.contentLabels[label])
) {
this.contentLabels[label as keyof LabelPreferencesModel] =
prefs.contentLabels[label]
}
}
if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) {
this.savedFeeds = prefs.feeds.saved
}
if (
prefs.feeds.pinned &&
!isEqual(this.pinnedFeeds, prefs.feeds.pinned)
) {
this.pinnedFeeds = prefs.feeds.pinned
}
this.birthDate = prefs.birthDate
})
// sync legacy values if needed
await this.syncLegacyPreferences()
// set defaults on missing items
if (typeof prefs.feeds.saved === 'undefined') {
try {
const {saved, pinned} = await DEFAULT_FEEDS(
this.rootStore.agent.service.toString(),
(handle: string) =>
this.rootStore.agent
.resolveHandle({handle})
.then(({data}) => data.did),
)
runInAction(() => {
this.savedFeeds = saved
this.pinnedFeeds = pinned
})
await this.rootStore.agent.setSavedFeeds(saved, pinned)
} catch (error) {
logger.error('Failed to set default feeds', {error})
}
}
} finally {
this.lock.release()
}
}
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.
*/
async reset() {
await this.lock.acquireAsync()
try {
runInAction(() => {
this.contentLabels = new LabelPreferencesModel()
this.savedFeeds = []
this.pinnedFeeds = []
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: [],
})
} finally {
this.lock.release()
}
}
// moderation
// =
async setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,
) {
this.contentLabels[key] = value
await this.rootStore.agent.setContentLabelPref(key, value)
}
async setAdultContentEnabled(v: boolean) {
this.adultContentEnabled = v
await this.rootStore.agent.setAdultContentEnabled(v)
}
/**
* @deprecated use `getModerationOpts` from '#/state/queries/preferences/moderation' instead
*/
get moderationOpts(): ModerationOpts {
return {
userDid: this.rootStore.session.currentSession?.did || '',
@ -284,274 +148,32 @@ export class PreferencesModel {
return this.pinnedFeeds.includes(uri)
}
async _optimisticUpdateSavedFeeds(
saved: string[],
pinned: string[],
cb: () => Promise<{saved: string[]; pinned: string[]}>,
) {
const oldSaved = this.savedFeeds
const oldPinned = this.pinnedFeeds
this.savedFeeds = saved
this.pinnedFeeds = pinned
await this.lock.acquireAsync()
try {
const res = await cb()
runInAction(() => {
this.savedFeeds = res.saved
this.pinnedFeeds = res.pinned
})
} catch (e) {
runInAction(() => {
this.savedFeeds = oldSaved
this.pinnedFeeds = oldPinned
})
throw e
} finally {
this.lock.release()
}
}
/**
* @deprecated use `useAddSavedFeedMutation` from `#/state/queries/preferences` instead
*/
async addSavedFeed(_v: string) {}
async setSavedFeeds(saved: string[], pinned: string[]) {
return this._optimisticUpdateSavedFeeds(saved, pinned, () =>
this.rootStore.agent.setSavedFeeds(saved, pinned),
)
}
/**
* @deprecated use `useRemoveSavedFeedMutation` from `#/state/queries/preferences` instead
*/
async removeSavedFeed(_v: string) {}
async addSavedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
[...this.savedFeeds.filter(uri => uri !== v), v],
this.pinnedFeeds,
() => this.rootStore.agent.addSavedFeed(v),
)
}
/**
* @deprecated use `usePinFeedMutation` from `#/state/queries/preferences` instead
*/
async addPinnedFeed(_v: string) {}
async removeSavedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
this.savedFeeds.filter(uri => uri !== v),
this.pinnedFeeds.filter(uri => uri !== v),
() => this.rootStore.agent.removeSavedFeed(v),
)
}
async addPinnedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
[...this.savedFeeds.filter(uri => uri !== v), v],
[...this.pinnedFeeds.filter(uri => uri !== v), v],
() => this.rootStore.agent.addPinnedFeed(v),
)
}
async removePinnedFeed(v: string) {
return this._optimisticUpdateSavedFeeds(
this.savedFeeds,
this.pinnedFeeds.filter(uri => uri !== v),
() => this.rootStore.agent.removePinnedFeed(v),
)
}
// other
// =
async setBirthDate(birthDate: Date) {
this.birthDate = birthDate
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setPersonalDetails({birthDate})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideReplies() {
this.homeFeed.hideReplies = !this.homeFeed.hideReplies
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideReplies: this.homeFeed.hideReplies,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideRepliesByUnfollowed() {
this.homeFeed.hideRepliesByUnfollowed =
!this.homeFeed.hideRepliesByUnfollowed
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed,
})
} finally {
this.lock.release()
}
}
async setHomeFeedHideRepliesByLikeCount(threshold: number) {
this.homeFeed.hideRepliesByLikeCount = threshold
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideReposts() {
this.homeFeed.hideReposts = !this.homeFeed.hideReposts
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideReposts: this.homeFeed.hideReposts,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedHideQuotePosts() {
this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
hideQuotePosts: this.homeFeed.hideQuotePosts,
})
} finally {
this.lock.release()
}
}
async toggleHomeFeedMergeFeedEnabled() {
this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setFeedViewPrefs('home', {
lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled,
})
} finally {
this.lock.release()
}
}
async setThreadSort(v: string) {
if (THREAD_SORT_VALUES.includes(v)) {
this.thread.sort = v
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({sort: v})
} finally {
this.lock.release()
}
}
}
async togglePrioritizedFollowedUsers() {
this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({
prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers,
})
} finally {
this.lock.release()
}
}
async toggleThreadTreeViewEnabled() {
this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled
await this.lock.acquireAsync()
try {
await this.rootStore.agent.setThreadViewPrefs({
lab_treeViewEnabled: this.thread.lab_treeViewEnabled,
})
} finally {
this.lock.release()
}
}
getFeedTuners(
feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
) {
if (feedType === 'custom') {
return [
FeedTuner.dedupReposts,
FeedTuner.preferredLangOnly(getContentLanguages()),
]
}
if (feedType === 'list') {
return [FeedTuner.dedupReposts]
}
if (feedType === 'home' || feedType === 'following') {
const feedTuners = []
if (this.homeFeed.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
} else {
feedTuners.push(FeedTuner.dedupReposts)
}
if (this.homeFeed.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
FeedTuner.thresholdRepliesOnly({
userDid: this.rootStore.session.data?.did || '',
minLikes: this.homeFeed.hideRepliesByLikeCount,
followedOnly: !!this.homeFeed.hideRepliesByUnfollowed,
}),
)
}
if (this.homeFeed.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
return feedTuners
}
return []
}
/**
* @deprecated use `useUnpinFeedMutation` from `#/state/queries/preferences` instead
*/
async removePinnedFeed(_v: string) {}
}
// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
// TODO do we need this?
function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
if (pref === 'show') {
return 'ignore'
}
return pref
}
function getLegacyPreferences(
v: Record<string, unknown>,
): 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
}