Merge branch 'main' into inherit_system_theme

This commit is contained in:
Jaz
2023-05-30 18:25:29 -07:00
committed by GitHub
136 changed files with 5771 additions and 2428 deletions
+153 -4
View File
@@ -11,6 +11,7 @@ import {
ALWAYS_FILTER_LABEL_GROUP,
ALWAYS_WARN_LABEL_GROUP,
} from 'lib/labeling/const'
import {DEFAULT_FEEDS} from 'lib/constants'
import {isIOS} from 'platform/detection'
const deviceLocales = getLocales()
@@ -25,6 +26,7 @@ const LABEL_GROUPS = [
'spam',
'impersonation',
]
const VISIBILITY_VALUES = ['show', 'warn', 'hide']
export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide'
@@ -45,6 +47,8 @@ export class PreferencesModel {
contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || []
contentLabels = new LabelPreferencesModel()
savedFeeds: string[] = []
pinnedFeeds: string[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {}, {autoBind: true})
@@ -54,9 +58,16 @@ export class PreferencesModel {
return {
contentLanguages: this.contentLanguages,
contentLabels: this.contentLabels,
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
}
}
/**
* The function hydrates an object with properties related to content languages, labels, saved feeds,
* and pinned feeds that it gets from the parameter `v` (probably local storage)
* @param {unknown} v - the data object to hydrate from
*/
hydrate(v: unknown) {
if (isObj(v)) {
if (
@@ -72,10 +83,29 @@ export class PreferencesModel {
// default to the device languages
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
}
if (
hasProp(v, 'savedFeeds') &&
Array.isArray(v.savedFeeds) &&
typeof v.savedFeeds.every(item => typeof item === 'string')
) {
this.savedFeeds = v.savedFeeds
}
if (
hasProp(v, 'pinnedFeeds') &&
Array.isArray(v.pinnedFeeds) &&
typeof v.pinnedFeeds.every(item => typeof item === 'string')
) {
this.pinnedFeeds = v.pinnedFeeds
}
}
}
/**
* This function fetches preferences and sets defaults for missing items.
*/
async sync() {
// fetch preferences
let hasSavedFeedsPref = false
const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
runInAction(() => {
for (const pref of res.data.preferences) {
@@ -88,22 +118,83 @@ export class PreferencesModel {
AppBskyActorDefs.isContentLabelPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success
) {
if (LABEL_GROUPS.includes(pref.label)) {
this.contentLabels[pref.label] = pref.visibility
if (
LABEL_GROUPS.includes(pref.label) &&
VISIBILITY_VALUES.includes(pref.visibility)
) {
this.contentLabels[pref.label as keyof LabelPreferencesModel] =
pref.visibility as LabelPreference
}
} else if (
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success
) {
this.savedFeeds = pref.saved
this.pinnedFeeds = pref.pinned
hasSavedFeedsPref = true
}
}
})
// set defaults on missing items
if (!hasSavedFeedsPref) {
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
})
res.data.preferences.push({
$type: 'app.bsky.actor.defs#savedFeedsPref',
saved,
pinned,
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: res.data.preferences,
})
/* dont await */ this.rootStore.me.savedFeeds.refresh()
}
}
async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
/**
* This function updates the preferences of a user and allows for a callback function to be executed
* before the update.
* @param cb - cb is a callback function that takes in a single parameter of type
* AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to
* update the preferences of the user. The function is called with the current preferences as an
* argument and if the callback returns false, the preferences are not updated.
* @returns void
*/
async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) {
const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
cb(res.data.preferences)
if (cb(res.data.preferences) === false) {
return
}
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: res.data.preferences,
})
}
/**
* This function resets the preferences to an empty array of no preferences.
*/
async reset() {
runInAction(() => {
this.contentLabels = new LabelPreferencesModel()
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
this.savedFeeds = []
this.pinnedFeeds = []
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: [],
})
}
hasContentLanguage(code2: string) {
return this.contentLanguages.includes(code2)
}
@@ -200,4 +291,62 @@ export class PreferencesModel {
}
return res
}
setFeeds(saved: string[], pinned: string[]) {
this.savedFeeds = saved
this.pinnedFeeds = pinned
}
async setSavedFeeds(saved: string[], pinned: string[]) {
const oldSaved = this.savedFeeds
const oldPinned = this.pinnedFeeds
this.setFeeds(saved, pinned)
try {
await this.update((prefs: AppBskyActorDefs.Preferences) => {
const existing = prefs.find(
pref =>
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success,
)
if (existing) {
existing.saved = saved
existing.pinned = pinned
} else {
prefs.push({
$type: 'app.bsky.actor.defs#savedFeedsPref',
saved,
pinned,
})
}
})
} catch (e) {
runInAction(() => {
this.savedFeeds = oldSaved
this.pinnedFeeds = oldPinned
})
throw e
}
}
async addSavedFeed(v: string) {
return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds)
}
async removeSavedFeed(v: string) {
return this.setSavedFeeds(
this.savedFeeds.filter(uri => uri !== v),
this.pinnedFeeds.filter(uri => uri !== v),
)
}
async addPinnedFeed(v: string) {
return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v])
}
async removePinnedFeed(v: string) {
return this.setSavedFeeds(
this.savedFeeds,
this.pinnedFeeds.filter(uri => uri !== v),
)
}
}
+26 -10
View File
@@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile'
import {PostsFeedModel} from '../feeds/posts'
import {ActorFeedsModel} from '../lists/actor-feeds'
import {ListsListModel} from '../lists/lists-list'
export enum Sections {
Posts = 'Posts',
PostsWithReplies = 'Posts & replies',
CustomAlgorithms = 'Feeds',
Lists = 'Lists',
}
const USER_SELECTOR_ITEMS = [
Sections.Posts,
Sections.PostsWithReplies,
Sections.Lists,
]
export interface ProfileUiParams {
user: string
}
@@ -28,6 +24,7 @@ export class ProfileUiModel {
// data
profile: ProfileModel
feed: PostsFeedModel
algos: ActorFeedsModel
lists: ListsListModel
// ui state
@@ -50,10 +47,11 @@ export class ProfileUiModel {
actor: params.user,
limit: 10,
})
this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
this.lists = new ListsListModel(rootStore, params.user)
}
get currentView(): PostsFeedModel | ListsListModel {
get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies
@@ -62,6 +60,9 @@ export class ProfileUiModel {
} else if (this.selectedView === Sections.Lists) {
return this.lists
}
if (this.selectedView === Sections.CustomAlgorithms) {
return this.algos
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
@@ -75,7 +76,14 @@ export class ProfileUiModel {
}
get selectorItems() {
return USER_SELECTOR_ITEMS
const items = [Sections.Posts, Sections.PostsWithReplies]
if (this.algos.hasLoaded && !this.algos.isEmpty) {
items.push(Sections.CustomAlgorithms)
}
if (this.lists.hasLoaded && !this.lists.isEmpty) {
items.push(Sections.Lists)
}
return items
}
get selectedView() {
@@ -84,9 +92,11 @@ export class ProfileUiModel {
get uiItems() {
let arr: any[] = []
// if loading, return loading item to show loading spinner
if (this.isInitialLoading) {
arr = arr.concat([ProfileUiModel.LOADING_ITEM])
} else if (this.currentView.hasError) {
// if error, return error item to show error message
arr = arr.concat([
{
_reactKey: '__error__',
@@ -94,12 +104,16 @@ export class ProfileUiModel {
},
])
} else {
// not loading, no error, show content
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.CustomAlgorithms
) {
if (this.feed.hasContent) {
if (this.selectedView === Sections.Posts) {
if (this.selectedView === Sections.CustomAlgorithms) {
arr = this.algos.feeds
} else if (this.selectedView === Sections.Posts) {
arr = this.feed.nonReplyFeed
} else {
arr = this.feed.slices.slice()
@@ -117,6 +131,7 @@ export class ProfileUiModel {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else {
// fallback, add empty item, to show empty message
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
}
@@ -151,6 +166,7 @@ export class ProfileUiModel {
.setup()
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
])
this.algos.refresh()
// HACK: need to use the DID as a param, not the username -prf
this.lists.source = this.profile.did
this.lists
+185
View File
@@ -0,0 +1,185 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed'
export class SavedFeedsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
// data
feeds: CustomFeedModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get pinned() {
return this.rootStore.preferences.pinnedFeeds
.map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
.filter(Boolean)
}
get unpinned() {
return this.feeds.filter(f => !this.isPinned(f))
}
get all() {
return this.pinned.concat(this.unpinned)
}
get pinnedFeedNames() {
return this.pinned.map(f => f.displayName)
}
// public api
// =
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.feeds = []
}
refresh = bundleAsync(async (quietRefresh = false) => {
this._xLoading(!quietRefresh)
try {
let feeds: AppBskyFeedDefs.GeneratorView[] = []
for (
let i = 0;
i < this.rootStore.preferences.savedFeeds.length;
i += 25
) {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
feeds: this.rootStore.preferences.savedFeeds.slice(i, 25),
})
feeds = feeds.concat(res.data.feeds)
}
runInAction(() => {
this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
})
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
async save(feed: CustomFeedModel) {
try {
await feed.save()
runInAction(() => {
this.feeds = [
...this.feeds,
new CustomFeedModel(this.rootStore, feed.data),
]
})
} catch (e: any) {
this.rootStore.log.error('Failed to save feed', e)
}
}
async unsave(feed: CustomFeedModel) {
const uri = feed.uri
try {
if (this.isPinned(feed)) {
await this.rootStore.preferences.removePinnedFeed(uri)
}
await feed.unsave()
runInAction(() => {
this.feeds = this.feeds.filter(f => f.data.uri !== uri)
})
} catch (e: any) {
this.rootStore.log.error('Failed to unsave feed', e)
}
}
async togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) {
return this.rootStore.preferences.addPinnedFeed(feed.uri)
} else {
return this.rootStore.preferences.removePinnedFeed(feed.uri)
}
}
async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
return this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
)
}
isPinned(feedOrUri: CustomFeedModel | string) {
let uri: string
if (typeof feedOrUri === 'string') {
uri = feedOrUri
} else {
uri = feedOrUri.uri
}
return this.rootStore.preferences.pinnedFeeds.includes(uri)
}
async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri)
if (index === -1) {
return
}
if (direction === 'up' && index !== 0) {
const temp = pinned[index]
pinned[index] = pinned[index - 1]
pinned[index - 1] = temp
} else if (direction === 'down' && index < pinned.length - 1) {
const temp = pinned[index]
pinned[index] = pinned[index + 1]
pinned[index + 1] = temp
}
await this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
pinned,
)
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch user feeds', err)
}
}
}
+1 -1
View File
@@ -119,7 +119,7 @@ export type Modal =
// Moderation
| ReportAccountModal
| ReportPostModal
| CreateMuteListModal
| CreateOrEditMuteListModal
| ListAddRemoveUserModal
// Posts