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:
parent
f9944b55e2
commit
f57a8cf8ba
87 changed files with 4090 additions and 1988 deletions
223
src/state/models/content/feed-source.ts
Normal file
223
src/state/models/content/feed-source.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ export class ProfileViewerModel {
|
|||
following?: string
|
||||
followedBy?: string
|
||||
blockedBy?: boolean
|
||||
blocking?: string;
|
||||
blocking?: string
|
||||
blockingByList?: AppBskyGraphDefs.ListViewBasic;
|
||||
[key: string]: unknown
|
||||
|
||||
constructor() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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()])
|
||||
}
|
||||
|
|
|
@ -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 = []
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue