Lists updates: curate lists and blocklists (#1689)

* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Paul Frazee 2023-11-01 16:15:40 -07:00 committed by GitHub
parent f9944b55e2
commit f57a8cf8ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4090 additions and 1988 deletions

View file

@ -0,0 +1,223 @@
import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/models/root-store'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {track} from 'lib/analytics/analytics'
export class FeedSourceModel {
// state
_reactKey: string
hasLoaded = false
error: string | undefined
// data
uri: string
cid: string = ''
type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
avatar: string | undefined = ''
displayName: string = ''
descriptionRT: RichText | null = null
creatorDid: string = ''
creatorHandle: string = ''
likeCount: number | undefined = 0
likeUri: string | undefined = ''
constructor(public rootStore: RootStoreModel, uri: string) {
this._reactKey = uri
this.uri = uri
try {
const urip = new AtUri(uri)
if (urip.collection === 'app.bsky.feed.generator') {
this.type = 'feed-generator'
} else if (urip.collection === 'app.bsky.graph.list') {
this.type = 'list'
}
} catch {}
this.displayName = uri.split('/').pop() || ''
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get href() {
const urip = new AtUri(this.uri)
const collection =
urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
}
get isSaved() {
return this.rootStore.preferences.savedFeeds.includes(this.uri)
}
get isPinned() {
return this.rootStore.preferences.isPinnedFeed(this.uri)
}
get isLiked() {
return !!this.likeUri
}
get isOwner() {
return this.creatorDid === this.rootStore.me.did
}
setup = bundleAsync(async () => {
try {
if (this.type === 'feed-generator') {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
feed: this.uri,
})
this.hydrateFeedGenerator(res.data.view)
} else if (this.type === 'list') {
const res = await this.rootStore.agent.app.bsky.graph.getList({
list: this.uri,
limit: 1,
})
this.hydrateList(res.data.list)
}
} catch (e) {
runInAction(() => {
this.error = cleanError(e)
})
}
})
hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.displayName
? sanitizeDisplayName(view.displayName)
: `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = view.likeCount
this.likeUri = view.viewer?.like
this.hasLoaded = true
}
hydrateList(view: AppBskyGraphDefs.ListView) {
this.uri = view.uri
this.cid = view.cid
this.avatar = view.avatar
this.displayName = view.name
? sanitizeDisplayName(view.name)
: `User List by ${sanitizeHandle(view.creator.handle, '@')}`
this.descriptionRT = new RichText({
text: view.description || '',
facets: (view.descriptionFacets || [])?.slice(),
})
this.creatorDid = view.creator.did
this.creatorHandle = view.creator.handle
this.likeCount = undefined
this.hasLoaded = true
}
async save() {
if (this.type !== 'feed-generator') {
return
}
try {
await this.rootStore.preferences.addSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to save feed', error)
} finally {
track('CustomFeed:Save')
}
}
async unsave() {
if (this.type !== 'feed-generator') {
return
}
try {
await this.rootStore.preferences.removeSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to unsave feed', error)
} finally {
track('CustomFeed:Unsave')
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to pin feed', error)
} finally {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
}
}
async togglePin() {
if (!this.isPinned) {
track('CustomFeed:Pin', {
name: this.displayName,
uri: this.uri,
})
return this.rootStore.preferences.addPinnedFeed(this.uri)
} else {
track('CustomFeed:Unpin', {
name: this.displayName,
uri: this.uri,
})
return this.rootStore.preferences.removePinnedFeed(this.uri)
}
}
async like() {
if (this.type !== 'feed-generator') {
return
}
try {
this.likeUri = 'pending'
this.likeCount = (this.likeCount || 0) + 1
const res = await this.rootStore.agent.like(this.uri, this.cid)
this.likeUri = res.uri
} catch (e: any) {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
this.rootStore.log.error('Failed to like feed', e)
} finally {
track('CustomFeed:Like')
}
}
async unlike() {
if (this.type !== 'feed-generator') {
return
}
if (!this.likeUri) {
return
}
const uri = this.likeUri
try {
this.likeUri = undefined
this.likeCount = (this.likeCount || 1) - 1
await this.rootStore.agent.deleteLike(uri!)
} catch (e: any) {
this.likeUri = uri
this.likeCount = (this.likeCount || 0) + 1
this.rootStore.log.error('Failed to unlike feed', e)
} finally {
track('CustomFeed:Unlike')
}
}
}

View file

@ -110,14 +110,21 @@ export class ListMembershipModel {
})
}
async updateTo(uris: string[]) {
async updateTo(
uris: string[],
): Promise<{added: string[]; removed: string[]}> {
const added = []
const removed = []
for (const uri of uris) {
await this.add(uri)
added.push(uri)
}
for (const membership of this.memberships) {
if (!uris.includes(membership.value.list)) {
await this.remove(membership.value.list)
removed.push(membership.value.list)
}
}
return {added, removed}
}
}

View file

@ -1,10 +1,12 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {
AtUri,
AppBskyActorDefs,
AppBskyGraphGetList as GetList,
AppBskyGraphDefs as GraphDefs,
AppBskyGraphList,
AppBskyGraphListitem,
RichText,
} from '@atproto/api'
import {Image as RNImage} from 'react-native-image-crop-picker'
import chunk from 'lodash.chunk'
@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {track} from 'lib/analytics/analytics'
import {until} from 'lib/async/until'
const PAGE_SIZE = 30
@ -37,19 +40,32 @@ export class ListModel {
loadMoreCursor?: string
// data
list: GraphDefs.ListView | null = null
data: GraphDefs.ListView | null = null
items: GraphDefs.ListItemView[] = []
descriptionRT: RichText | null = null
static async createModList(
static async createList(
rootStore: RootStoreModel,
{
purpose,
name,
description,
avatar,
}: {name: string; description: string; avatar: RNImage | null | undefined},
}: {
purpose: string
name: string
description: string
avatar: RNImage | null | undefined
},
) {
if (
purpose !== 'app.bsky.graph.defs#curatelist' &&
purpose !== 'app.bsky.graph.defs#modlist'
) {
throw new Error('Invalid list purpose: must be curatelist or modlist')
}
const record: AppBskyGraphList.Record = {
purpose: 'app.bsky.graph.defs#modlist',
purpose,
name,
description,
avatar: undefined,
@ -69,7 +85,20 @@ export class ListModel {
},
record,
)
await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
// wait for the appview to update
await until(
5, // 5 tries
1e3, // 1s delay between tries
(v: GetList.Response, _e: any) => {
return typeof v?.data?.list.uri === 'string'
},
() =>
rootStore.agent.app.bsky.graph.getList({
list: res.uri,
limit: 1,
}),
)
return res
}
@ -95,16 +124,40 @@ export class ListModel {
return this.hasLoaded && !this.hasContent
}
get isOwner() {
return this.list?.creator.did === this.rootStore.me.did
get isCuratelist() {
return this.data?.purpose === 'app.bsky.graph.defs#curatelist'
}
get isSubscribed() {
return this.list?.viewer?.muted
get isModlist() {
return this.data?.purpose === 'app.bsky.graph.defs#modlist'
}
get isOwner() {
return this.data?.creator.did === this.rootStore.me.did
}
get isBlocking() {
return !!this.data?.viewer?.blocked
}
get isMuting() {
return !!this.data?.viewer?.muted
}
get isPinned() {
return this.rootStore.preferences.isPinnedFeed(this.uri)
}
get creatorDid() {
return this.list?.creator.did
return this.data?.creator.did
}
getMembership(did: string) {
return this.items.find(item => item.subject.did === did)
}
isMember(did: string) {
return !!this.getMembership(did)
}
// public api
@ -137,6 +190,15 @@ export class ListModel {
}
})
async loadAll() {
for (let i = 0; i < 1000; i++) {
if (!this.hasMore) {
break
}
await this.loadMore()
}
}
async updateMetadata({
name,
description,
@ -146,7 +208,7 @@ export class ListModel {
description: string
avatar: RNImage | null | undefined
}) {
if (!this.list) {
if (!this.data) {
return
}
if (!this.isOwner) {
@ -183,7 +245,7 @@ export class ListModel {
}
async delete() {
if (!this.list) {
if (!this.data) {
return
}
await this._resolveUri()
@ -231,28 +293,140 @@ export class ListModel {
this.rootStore.emitListDeleted(this.uri)
}
async subscribe() {
if (!this.list) {
async addMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (this.isMember(profile.did)) {
return
}
await this._resolveUri()
await this.rootStore.agent.app.bsky.graph.muteActorList({
list: this.list.uri,
await this.rootStore.agent.app.bsky.graph.listitem.create(
{
repo: this.rootStore.me.did,
},
{
subject: profile.did,
list: this.uri,
createdAt: new Date().toISOString(),
},
)
runInAction(() => {
this.items = this.items.concat([
{_reactKey: profile.did, subject: profile},
])
})
track('Lists:Subscribe')
await this.refresh()
}
async unsubscribe() {
if (!this.list) {
/**
* Just adds to local cache; used to reflect changes affected elsewhere
*/
cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (!this.isMember(profile.did)) {
this.items = this.items.concat([
{_reactKey: profile.did, subject: profile},
])
}
}
/**
* Just removes from local cache; used to reflect changes affected elsewhere
*/
cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) {
if (this.isMember(profile.did)) {
this.items = this.items.filter(item => item.subject.did !== profile.did)
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to pin feed', error)
} finally {
track('CustomFeed:Pin', {
name: this.data?.name || '',
uri: this.uri,
})
}
}
async togglePin() {
if (!this.isPinned) {
track('CustomFeed:Pin', {
name: this.data?.name || '',
uri: this.uri,
})
return this.rootStore.preferences.addPinnedFeed(this.uri)
} else {
track('CustomFeed:Unpin', {
name: this.data?.name || '',
uri: this.uri,
})
// TEMPORARY
// lists are temporarily piggybacking on the saved/pinned feeds preferences
// we'll eventually replace saved feeds with the bookmarks API
// until then, we need to unsave lists instead of just unpin them
// -prf
// return this.rootStore.preferences.removePinnedFeed(this.uri)
return this.rootStore.preferences.removeSavedFeed(this.uri)
}
}
async mute() {
if (!this.data) {
return
}
await this._resolveUri()
await this.rootStore.agent.app.bsky.graph.unmuteActorList({
list: this.list.uri,
await this.rootStore.agent.muteModList(this.data.uri)
track('Lists:Mute')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), muted: true}}
}
})
}
async unmute() {
if (!this.data) {
return
}
await this._resolveUri()
await this.rootStore.agent.unmuteModList(this.data.uri)
track('Lists:Unmute')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), muted: false}}
}
})
}
async block() {
if (!this.data) {
return
}
await this._resolveUri()
const res = await this.rootStore.agent.blockModList(this.data.uri)
track('Lists:Block')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}}
}
})
}
async unblock() {
if (!this.data || !this.data.viewer?.blocked) {
return
}
await this._resolveUri()
await this.rootStore.agent.unblockModList(this.data.uri)
track('Lists:Unblock')
runInAction(() => {
if (this.data) {
const d = this.data
this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}}
}
})
track('Lists:Unsubscribe')
await this.refresh()
}
/**
@ -314,9 +488,17 @@ export class ListModel {
_appendAll(res: GetList.Response) {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
this.list = res.data.list
this.data = res.data.list
this.items = this.items.concat(
res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
)
if (this.data.description) {
this.descriptionRT = new RichText({
text: this.data.description,
facets: (this.data.descriptionFacets || [])?.slice(),
})
} else {
this.descriptionRT = null
}
}
}

View file

@ -22,7 +22,8 @@ export class ProfileViewerModel {
following?: string
followedBy?: string
blockedBy?: boolean
blocking?: string;
blocking?: string
blockingByList?: AppBskyGraphDefs.ListViewBasic;
[key: string]: unknown
constructor() {

View file

@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} 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'
import {FeedSourceModel} from '../content/feed-source'
const DEFAULT_LIMIT = 50
@ -16,7 +16,7 @@ export class FeedsDiscoveryModel {
loadMoreCursor: string | undefined = undefined
// data
feeds: CustomFeedModel[] = []
feeds: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@ -137,7 +137,9 @@ export class FeedsDiscoveryModel {
_append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
// 1. push data into feeds array
for (const f of res.data.feeds) {
this.feeds.push(new CustomFeedModel(this.rootStore, f))
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
// 2. set loadMoreCursor
this.loadMoreCursor = res.data.cursor

View file

@ -1,151 +0,0 @@
import {AppBskyFeedDefs} from '@atproto/api'
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/models/root-store'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {updateDataOptimistically} from 'lib/async/revertible'
import {track} from 'lib/analytics/analytics'
export class CustomFeedModel {
// data
_reactKey: string
data: AppBskyFeedDefs.GeneratorView
isOnline: boolean
isValid: boolean
constructor(
public rootStore: RootStoreModel,
view: AppBskyFeedDefs.GeneratorView,
isOnline?: boolean,
isValid?: boolean,
) {
this._reactKey = view.uri
this.data = view
this.isOnline = isOnline ?? true
this.isValid = isValid ?? true
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
// local actions
// =
get uri() {
return this.data.uri
}
get displayName() {
if (this.data.displayName) {
return sanitizeDisplayName(this.data.displayName)
}
return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}`
}
get isSaved() {
return this.rootStore.preferences.savedFeeds.includes(this.uri)
}
get isLiked() {
return this.data.viewer?.like
}
// public apis
// =
async save() {
try {
await this.rootStore.preferences.addSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to save feed', error)
} finally {
track('CustomFeed:Save')
}
}
async pin() {
try {
await this.rootStore.preferences.addPinnedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to pin feed', error)
} finally {
track('CustomFeed:Pin', {
name: this.data.displayName,
uri: this.uri,
})
}
}
async unsave() {
try {
await this.rootStore.preferences.removeSavedFeed(this.uri)
} catch (error) {
this.rootStore.log.error('Failed to unsave feed', error)
} finally {
track('CustomFeed:Unsave')
}
}
async like() {
try {
await updateDataOptimistically(
this.data,
() => {
this.data.viewer = this.data.viewer || {}
this.data.viewer.like = 'pending'
this.data.likeCount = (this.data.likeCount || 0) + 1
},
() => this.rootStore.agent.like(this.data.uri, this.data.cid),
res => {
this.data.viewer = this.data.viewer || {}
this.data.viewer.like = res.uri
},
)
} catch (e: any) {
this.rootStore.log.error('Failed to like feed', e)
} finally {
track('CustomFeed:Like')
}
}
async unlike() {
if (!this.data.viewer?.like) {
return
}
try {
const likeUri = this.data.viewer.like
await updateDataOptimistically(
this.data,
() => {
this.data.viewer = this.data.viewer || {}
this.data.viewer.like = undefined
this.data.likeCount = (this.data.likeCount || 1) - 1
},
() => this.rootStore.agent.deleteLike(likeUri),
)
} catch (e: any) {
this.rootStore.log.error('Failed to unlike feed', e)
} finally {
track('CustomFeed:Unlike')
}
}
async reload() {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
feed: this.data.uri,
})
runInAction(() => {
this.data = res.data.view
this.isOnline = res.data.isOnline
this.isValid = res.data.isValid
})
}
serialize() {
return JSON.stringify(this.data)
}
}

View file

@ -4,6 +4,7 @@ import {
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
AppBskyFeedGetFeed as GetCustomFeed,
AppBskyFeedGetActorLikes as GetActorLikes,
AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
import AwaitLock from 'await-lock'
import {bundleAsync} from 'lib/async/bundle'
@ -19,6 +20,7 @@ import {FollowingFeedAPI} from 'lib/api/feed/following'
import {AuthorFeedAPI} from 'lib/api/feed/author'
import {LikesFeedAPI} from 'lib/api/feed/likes'
import {CustomFeedAPI} from 'lib/api/feed/custom'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
const PAGE_SIZE = 30
@ -36,6 +38,7 @@ type QueryParams =
| GetAuthorFeed.QueryParams
| GetActorLikes.QueryParams
| GetCustomFeed.QueryParams
| GetListFeed.QueryParams
export class PostsFeedModel {
// state
@ -66,7 +69,13 @@ export class PostsFeedModel {
constructor(
public rootStore: RootStoreModel,
public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
public feedType:
| 'home'
| 'following'
| 'author'
| 'custom'
| 'likes'
| 'list',
params: QueryParams,
options?: Options,
) {
@ -99,11 +108,26 @@ export class PostsFeedModel {
rootStore,
params as GetCustomFeed.QueryParams,
)
} else if (feedType === 'list') {
this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams)
} else {
this.api = new FollowingFeedAPI(rootStore)
}
}
get reactKey() {
if (this.feedType === 'author') {
return (this.params as GetAuthorFeed.QueryParams).actor
}
if (this.feedType === 'custom') {
return (this.params as GetCustomFeed.QueryParams).feed
}
if (this.feedType === 'list') {
return (this.params as GetListFeed.QueryParams).list
}
return this.feedType
}
get hasContent() {
return this.slices.length !== 0
}
@ -117,7 +141,7 @@ export class PostsFeedModel {
}
get isLoadingMore() {
return this.isLoading && !this.isRefreshing
return this.isLoading && !this.isRefreshing && this.hasContent
}
setHasNewLatest(v: boolean) {

View file

@ -3,7 +3,7 @@ import {AppBskyFeedGetActorFeeds as GetActorFeeds} 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'
import {FeedSourceModel} from '../content/feed-source'
const PAGE_SIZE = 30
@ -17,7 +17,7 @@ export class ActorFeedsModel {
loadMoreCursor?: string
// data
feeds: CustomFeedModel[] = []
feeds: FeedSourceModel[] = []
constructor(
public rootStore: RootStoreModel,
@ -114,7 +114,9 @@ export class ActorFeedsModel {
this.loadMoreCursor = res.data.cursor
this.hasMore = !!this.loadMoreCursor
for (const f of res.data.feeds) {
this.feeds.push(new CustomFeedModel(this.rootStore, f))
const model = new FeedSourceModel(this.rootStore, f.uri)
model.hydrateFeedGenerator(f)
this.feeds.push(model)
}
}
}

View file

@ -1,12 +1,9 @@
import {makeAutoObservable} from 'mobx'
import {
AppBskyGraphGetLists as GetLists,
AppBskyGraphGetListMutes as GetListMutes,
AppBskyGraphDefs as GraphDefs,
} from '@atproto/api'
import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {cleanError} from 'lib/strings/errors'
import {bundleAsync} from 'lib/async/bundle'
import {accumulate} from 'lib/async/accumulate'
const PAGE_SIZE = 30
@ -25,7 +22,7 @@ export class ListsListModel {
constructor(
public rootStore: RootStoreModel,
public source: 'my-modlists' | string,
public source: 'mine' | 'my-curatelists' | 'my-modlists' | string,
) {
makeAutoObservable(
this,
@ -48,6 +45,26 @@ export class ListsListModel {
return this.hasLoaded && !this.hasContent
}
get curatelists() {
return this.lists.filter(
list => list.purpose === 'app.bsky.graph.defs#curatelist',
)
}
get isCuratelistsEmpty() {
return this.hasLoaded && this.curatelists.length === 0
}
get modlists() {
return this.lists.filter(
list => list.purpose === 'app.bsky.graph.defs#modlist',
)
}
get isModlistsEmpty() {
return this.hasLoaded && this.modlists.length === 0
}
/**
* Removes posts from the feed upon deletion.
*/
@ -76,44 +93,85 @@ export class ListsListModel {
}
this._xLoading(replace)
try {
let res: GetLists.Response
if (this.source === 'my-modlists') {
res = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
let cursor: string | undefined
let lists: GraphDefs.ListView[] = []
if (
this.source === 'mine' ||
this.source === 'my-curatelists' ||
this.source === 'my-modlists'
) {
const promises = [
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getLists({
actor: this.rootStore.me.did,
cursor,
limit: 50,
})
.then(res => ({cursor: res.data.cursor, items: res.data.lists})),
),
]
if (this.source === 'my-modlists') {
promises.push(
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getListMutes({
cursor,
limit: 50,
})
.then(res => ({
cursor: res.data.cursor,
items: res.data.lists,
})),
),
)
promises.push(
accumulate(cursor =>
this.rootStore.agent.app.bsky.graph
.getListBlocks({
cursor,
limit: 50,
})
.then(res => ({
cursor: res.data.cursor,
items: res.data.lists,
})),
),
)
}
const [res1, res2] = await Promise.all([
fetchAllUserLists(this.rootStore, this.rootStore.me.did),
fetchAllMyMuteLists(this.rootStore),
])
for (let list of res1.data.lists) {
if (list.purpose === 'app.bsky.graph.defs#modlist') {
res.data.lists.push(list)
}
}
for (let list of res2.data.lists) {
if (
list.purpose === 'app.bsky.graph.defs#modlist' &&
!res.data.lists.find(l => l.uri === list.uri)
) {
res.data.lists.push(list)
const resultset = await Promise.all(promises)
for (const res of resultset) {
for (let list of res) {
if (
this.source === 'my-curatelists' &&
list.purpose !== 'app.bsky.graph.defs#curatelist'
) {
continue
}
if (
this.source === 'my-modlists' &&
list.purpose !== 'app.bsky.graph.defs#modlist'
) {
continue
}
if (!lists.find(l => l.uri === list.uri)) {
lists.push(list)
}
}
}
} else {
res = await this.rootStore.agent.app.bsky.graph.getLists({
const res = await this.rootStore.agent.app.bsky.graph.getLists({
actor: this.source,
limit: PAGE_SIZE,
cursor: replace ? undefined : this.loadMoreCursor,
})
lists = res.data.lists
cursor = res.data.cursor
}
if (replace) {
this._replaceAll(res)
this._replaceAll({lists, cursor})
} else {
this._appendAll(res)
this._appendAll({lists, cursor})
}
this._xIdle()
} catch (e: any) {
@ -156,75 +214,28 @@ export class ListsListModel {
// helper functions
// =
_replaceAll(res: GetLists.Response | GetListMutes.Response) {
_replaceAll({
lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.lists = []
this._appendAll(res)
this._appendAll({lists, cursor})
}
_appendAll(res: GetLists.Response | GetListMutes.Response) {
this.loadMoreCursor = res.data.cursor
_appendAll({
lists,
cursor,
}: {
lists: GraphDefs.ListView[]
cursor: string | undefined
}) {
this.loadMoreCursor = cursor
this.hasMore = !!this.loadMoreCursor
this.lists = this.lists.concat(
res.data.lists.map(list => ({...list, _reactKey: list.uri})),
lists.map(list => ({...list, _reactKey: list.uri})),
)
}
}
async function fetchAllUserLists(
store: RootStoreModel,
did: string,
): Promise<GetLists.Response> {
let acc: GetLists.Response = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
}
let cursor
for (let i = 0; i < 100; i++) {
const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({
actor: did,
cursor,
limit: 50,
})
cursor = res.data.cursor
acc.data.lists = acc.data.lists.concat(res.data.lists)
if (!cursor) {
break
}
}
return acc
}
async function fetchAllMyMuteLists(
store: RootStoreModel,
): Promise<GetListMutes.Response> {
let acc: GetListMutes.Response = {
success: true,
headers: {},
data: {
subject: undefined,
lists: [],
},
}
let cursor
for (let i = 0; i < 100; i++) {
const res: GetListMutes.Response =
await store.agent.app.bsky.graph.getListMutes({
cursor,
limit: 50,
})
cursor = res.data.cursor
acc.data.lists = acc.data.lists.concat(res.data.lists)
if (!cursor) {
break
}
}
return acc
}

View file

@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts'
import {NotificationsFeedModel} from './feeds/notifications'
import {MyFollowsCache} from './cache/my-follows'
import {isObj, hasProp} from 'lib/type-guards'
import {SavedFeedsModel} from './ui/saved-feeds'
const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
@ -22,7 +21,6 @@ export class MeModel {
followsCount: number | undefined
followersCount: number | undefined
mainFeed: PostsFeedModel
savedFeeds: SavedFeedsModel
notifications: NotificationsFeedModel
follows: MyFollowsCache
invites: ComAtprotoServerDefs.InviteCode[] = []
@ -45,7 +43,6 @@ export class MeModel {
})
this.notifications = new NotificationsFeedModel(this.rootStore)
this.follows = new MyFollowsCache(this.rootStore)
this.savedFeeds = new SavedFeedsModel(this.rootStore)
}
clear() {

View file

@ -1,6 +1,7 @@
import {makeAutoObservable} from 'mobx'
import {makeAutoObservable, reaction} from 'mobx'
import {SavedFeedsModel} from './saved-feeds'
import {FeedsDiscoveryModel} from '../discovery/feeds'
import {CustomFeedModel} from '../feeds/custom-feed'
import {FeedSourceModel} from '../content/feed-source'
import {RootStoreModel} from '../root-store'
export type MyFeedsItem =
@ -29,7 +30,7 @@ export type MyFeedsItem =
| {
_reactKey: string
type: 'saved-feed'
feed: CustomFeedModel
feed: FeedSourceModel
}
| {
_reactKey: string
@ -46,21 +47,19 @@ export type MyFeedsItem =
| {
_reactKey: string
type: 'discover-feed'
feed: CustomFeedModel
feed: FeedSourceModel
}
export class MyFeedsUIModel {
saved: SavedFeedsModel
discovery: FeedsDiscoveryModel
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this)
this.saved = new SavedFeedsModel(this.rootStore)
this.discovery = new FeedsDiscoveryModel(this.rootStore)
}
get saved() {
return this.rootStore.me.savedFeeds
}
get isRefreshing() {
return !this.saved.isLoading && this.saved.isRefreshing
}
@ -78,6 +77,21 @@ export class MyFeedsUIModel {
}
}
registerListeners() {
const dispose1 = reaction(
() => this.rootStore.preferences.savedFeeds,
() => this.saved.refresh(),
)
const dispose2 = reaction(
() => this.rootStore.preferences.pinnedFeeds,
() => this.saved.refresh(),
)
return () => {
dispose1()
dispose2()
}
}
async refresh() {
return Promise.all([this.saved.refresh(), this.discovery.refresh()])
}

View file

@ -194,7 +194,7 @@ export class PreferencesModel {
/**
* This function fetches preferences and sets defaults for missing items.
*/
async sync({clearCache}: {clearCache?: boolean} = {}) {
async sync() {
await this.lock.acquireAsync()
try {
// fetch preferences
@ -252,8 +252,6 @@ export class PreferencesModel {
} finally {
this.lock.release()
}
await this.rootStore.me.savedFeeds.updateCache(clearCache)
}
async syncLegacyPreferences() {
@ -286,6 +284,9 @@ export class PreferencesModel {
}
}
// languages
// =
hasContentLanguage(code2: string) {
return this.contentLanguages.includes(code2)
}
@ -358,6 +359,9 @@ export class PreferencesModel {
return all.join(', ')
}
// moderation
// =
async setContentLabelPref(
key: keyof LabelPreferencesModel,
value: LabelPreference,
@ -409,6 +413,13 @@ export class PreferencesModel {
}
}
// feeds
// =
isPinnedFeed(uri: string) {
return this.pinnedFeeds.includes(uri)
}
async _optimisticUpdateSavedFeeds(
saved: string[],
pinned: string[],
@ -474,6 +485,9 @@ export class PreferencesModel {
)
}
// other
// =
async setBirthDate(birthDate: Date) {
this.birthDate = birthDate
await this.lock.acquireAsync()
@ -602,7 +616,7 @@ export class PreferencesModel {
}
getFeedTuners(
feedType: 'home' | 'following' | 'author' | 'custom' | 'likes',
feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes',
) {
if (feedType === 'custom') {
return [
@ -610,6 +624,9 @@ export class PreferencesModel {
FeedTuner.preferredLangOnly(this.contentLanguages),
]
}
if (feedType === 'list') {
return [FeedTuner.dedupReposts]
}
if (feedType === 'home' || feedType === 'following') {
const feedTuners = []

View file

@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed'
import {FeedSourceModel} from '../content/feed-source'
import {track} from 'lib/analytics/analytics'
export class SavedFeedsModel {
@ -13,7 +13,7 @@ export class SavedFeedsModel {
error = ''
// data
_feedModelCache: Record<string, CustomFeedModel> = {}
all: FeedSourceModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
@ -38,20 +38,11 @@ export class SavedFeedsModel {
}
get pinned() {
return this.rootStore.preferences.pinnedFeeds
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
.filter(Boolean)
return this.all.filter(feed => feed.isPinned)
}
get unpinned() {
return this.rootStore.preferences.savedFeeds
.filter(uri => !this.isPinned(uri))
.map(uri => this._feedModelCache[uri] as CustomFeedModel)
.filter(Boolean)
}
get all() {
return [...this.pinned, ...this.unpinned]
return this.all.filter(feed => !feed.isPinned)
}
get pinnedFeedNames() {
@ -61,121 +52,39 @@ export class SavedFeedsModel {
// public api
// =
/**
* Syncs the cached models against the current state
* - Should only be called by the preferences model after syncing state
*/
updateCache = bundleAsync(async (clearCache?: boolean) => {
let newFeedModels: Record<string, CustomFeedModel> = {}
if (!clearCache) {
newFeedModels = {...this._feedModelCache}
}
// collect the feed URIs that havent been synced yet
const neededFeedUris = []
for (const feedUri of this.rootStore.preferences.savedFeeds) {
if (!(feedUri in newFeedModels)) {
neededFeedUris.push(feedUri)
}
}
// early exit if no feeds need to be fetched
if (!neededFeedUris.length || neededFeedUris.length === 0) {
return
}
// fetch the missing models
try {
for (let i = 0; i < neededFeedUris.length; i += 25) {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
feeds: neededFeedUris.slice(i, 25),
})
for (const feedInfo of res.data.feeds) {
newFeedModels[feedInfo.uri] = new CustomFeedModel(
this.rootStore,
feedInfo,
)
}
}
} catch (error) {
console.error('Failed to fetch feed models', error)
this.rootStore.log.error('Failed to fetch feed models', error)
}
// merge into the cache
runInAction(() => {
this._feedModelCache = newFeedModels
})
})
/**
* Refresh the preferences then reload all feed infos
*/
refresh = bundleAsync(async () => {
this._xLoading(true)
try {
await this.rootStore.preferences.sync({clearCache: true})
await this.rootStore.preferences.sync()
const uris = dedup(
this.rootStore.preferences.pinnedFeeds.concat(
this.rootStore.preferences.savedFeeds,
),
)
const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri))
await Promise.all(feeds.map(f => f.setup()))
runInAction(() => {
this.all = feeds
this._updatePinSortOrder()
})
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
async save(feed: CustomFeedModel) {
try {
await feed.save()
await this.updateCache()
} 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()
} catch (e: any) {
this.rootStore.log.error('Failed to unsave feed', e)
}
}
async togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) {
track('CustomFeed:Pin', {
name: feed.data.displayName,
uri: feed.uri,
})
return this.rootStore.preferences.addPinnedFeed(feed.uri)
} else {
track('CustomFeed:Unpin', {
name: feed.data.displayName,
uri: feed.uri,
})
return this.rootStore.preferences.removePinnedFeed(feed.uri)
}
}
async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
return this.rootStore.preferences.setSavedFeeds(
async reorderPinnedFeeds(feeds: FeedSourceModel[]) {
this._updatePinSortOrder(feeds.map(f => f.uri))
await this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
feeds.filter(feed => feed.isPinned).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') {
async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri)
if (index === -1) {
@ -194,8 +103,9 @@ export class SavedFeedsModel {
this.rootStore.preferences.savedFeeds,
pinned,
)
this._updatePinSortOrder()
track('CustomFeed:Reorder', {
name: item.data.displayName,
name: item.displayName,
uri: item.uri,
index: pinned.indexOf(item.uri),
})
@ -219,4 +129,20 @@ export class SavedFeedsModel {
this.rootStore.log.error('Failed to fetch user feeds', err)
}
}
// helpers
// =
_updatePinSortOrder(order?: string[]) {
order ??= this.rootStore.preferences.pinnedFeeds.concat(
this.rootStore.preferences.savedFeeds,
)
this.all.sort((a, b) => {
return order!.indexOf(a.uri) - order!.indexOf(b.uri)
})
}
}
function dedup(strings: string[]): string[] {
return Array.from(new Set(strings))
}

View file

@ -1,4 +1,4 @@
import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {makeAutoObservable, runInAction} from 'mobx'
import {ProfileModel} from '../content/profile'
@ -60,17 +60,25 @@ export type ReportModal = {
| {did: string}
)
export interface CreateOrEditMuteListModal {
name: 'create-or-edit-mute-list'
export interface CreateOrEditListModal {
name: 'create-or-edit-list'
purpose?: string
list?: ListModel
onSave?: (uri: string) => void
}
export interface ListAddRemoveUserModal {
name: 'list-add-remove-user'
export interface UserAddRemoveListsModal {
name: 'user-add-remove-lists'
subject: string
displayName: string
onUpdate?: () => void
onAdd?: (listUri: string) => void
onRemove?: (listUri: string) => void
}
export interface ListAddUserModal {
name: 'list-add-user'
list: ListModel
onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void
}
export interface EditImageModal {
@ -180,8 +188,11 @@ export type Modal =
// Moderation
| ModerationDetailsModal
| ReportModal
| CreateOrEditMuteListModal
| ListAddRemoveUserModal
// Lists
| CreateOrEditListModal
| UserAddRemoveListsModal
| ListAddUserModal
// Posts
| AltTextImageModal