Merge branch 'main' into inherit_system_theme
This commit is contained in:
commit
09ade363fd
136 changed files with 5771 additions and 2428 deletions
1
src/state/models/cache/image-sizes.ts
vendored
1
src/state/models/cache/image-sizes.ts
vendored
|
@ -16,6 +16,7 @@ export class ImageSizesCache {
|
|||
if (Dimensions) {
|
||||
return Dimensions
|
||||
}
|
||||
|
||||
const prom =
|
||||
this.activeRequests.get(uri) ||
|
||||
new Promise<Dimensions>(resolve => {
|
||||
|
|
|
@ -118,7 +118,7 @@ export class PostThreadItemModel {
|
|||
|
||||
assignTreeModels(
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
higlightedPostUri: string,
|
||||
highlightedPostUri: string,
|
||||
includeParent = true,
|
||||
includeChildren = true,
|
||||
) {
|
||||
|
@ -130,7 +130,12 @@ export class PostThreadItemModel {
|
|||
parentModel._showChildReplyLine = true
|
||||
if (v.parent.parent) {
|
||||
parentModel._showParentReplyLine = true
|
||||
parentModel.assignTreeModels(v.parent, higlightedPostUri, true, false)
|
||||
parentModel.assignTreeModels(
|
||||
v.parent,
|
||||
highlightedPostUri,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
this.parent = parentModel
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
|
||||
|
@ -147,10 +152,10 @@ export class PostThreadItemModel {
|
|||
const itemModel = new PostThreadItemModel(this.rootStore, item)
|
||||
itemModel._depth = this._depth + 1
|
||||
itemModel._showParentReplyLine =
|
||||
itemModel.parentUri !== higlightedPostUri && replies.length === 0
|
||||
itemModel.parentUri !== highlightedPostUri && replies.length === 0
|
||||
if (item.replies?.length) {
|
||||
itemModel._showChildReplyLine = true
|
||||
itemModel.assignTreeModels(item, higlightedPostUri, false, true)
|
||||
itemModel.assignTreeModels(item, highlightedPostUri, false, true)
|
||||
}
|
||||
replies.push(itemModel)
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
|
||||
|
|
97
src/state/models/discovery/feeds.ts
Normal file
97
src/state/models/discovery/feeds.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
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'
|
||||
|
||||
export class FeedsDiscoveryModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
|
||||
// data
|
||||
feeds: CustomFeedModel[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
}
|
||||
|
||||
get hasMore() {
|
||||
return false
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.feeds.length > 0
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
refresh = bundleAsync(async () => {
|
||||
this._xLoading()
|
||||
try {
|
||||
const res =
|
||||
await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators(
|
||||
{},
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
clear() {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.feeds = []
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading() {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = true
|
||||
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 popular feeds', err)
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) {
|
||||
this.feeds = []
|
||||
for (const f of res.data.feeds) {
|
||||
this.feeds.push(new CustomFeedModel(this.rootStore, f))
|
||||
}
|
||||
}
|
||||
}
|
120
src/state/models/feeds/custom-feed.ts
Normal file
120
src/state/models/feeds/custom-feed.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
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 {updateDataOptimistically} from 'lib/async/revertible'
|
||||
|
||||
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 @${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() {
|
||||
await this.rootStore.preferences.addSavedFeed(this.uri)
|
||||
}
|
||||
|
||||
async unsave() {
|
||||
await this.rootStore.preferences.removeSavedFeed(this.uri)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
216
src/state/models/feeds/multi-feed.ts
Normal file
216
src/state/models/feeds/multi-feed.ts
Normal file
|
@ -0,0 +1,216 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {CustomFeedModel} from './custom-feed'
|
||||
import {PostsFeedModel} from './posts'
|
||||
import {PostsFeedSliceModel} from './post'
|
||||
|
||||
const FEED_PAGE_SIZE = 5
|
||||
const FEEDS_PAGE_SIZE = 3
|
||||
|
||||
export type MultiFeedItem =
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'header'
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'feed-header'
|
||||
avatar: string | undefined
|
||||
title: string
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'feed-slice'
|
||||
slice: PostsFeedSliceModel
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'feed-loading'
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'feed-error'
|
||||
error: string
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'feed-footer'
|
||||
title: string
|
||||
uri: string
|
||||
}
|
||||
| {
|
||||
_reactKey: string
|
||||
type: 'footer'
|
||||
}
|
||||
|
||||
export class PostsMultiFeedModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
hasMore = true
|
||||
|
||||
// data
|
||||
feedInfos: CustomFeedModel[] = []
|
||||
feeds: PostsFeedModel[] = []
|
||||
|
||||
constructor(public rootStore: RootStoreModel) {
|
||||
makeAutoObservable(this, {rootStore: false}, {autoBind: true})
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.feeds.length !== 0
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
get items() {
|
||||
const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}]
|
||||
for (let i = 0; i < this.feedInfos.length; i++) {
|
||||
if (!this.feeds[i]) {
|
||||
break
|
||||
}
|
||||
const feed = this.feeds[i]
|
||||
const feedInfo = this.feedInfos[i]
|
||||
const urip = new AtUri(feedInfo.uri)
|
||||
items.push({
|
||||
_reactKey: `__feed_header_${i}__`,
|
||||
type: 'feed-header',
|
||||
avatar: feedInfo.data.avatar,
|
||||
title: feedInfo.displayName,
|
||||
})
|
||||
if (feed.isLoading) {
|
||||
items.push({
|
||||
_reactKey: `__feed_loading_${i}__`,
|
||||
type: 'feed-loading',
|
||||
})
|
||||
} else if (feed.hasError) {
|
||||
items.push({
|
||||
_reactKey: `__feed_error_${i}__`,
|
||||
type: 'feed-error',
|
||||
error: feed.error,
|
||||
})
|
||||
} else {
|
||||
for (let j = 0; j < feed.slices.length; j++) {
|
||||
items.push({
|
||||
_reactKey: `__feed_slice_${i}_${j}__`,
|
||||
type: 'feed-slice',
|
||||
slice: feed.slices[j],
|
||||
})
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
_reactKey: `__feed_footer_${i}__`,
|
||||
type: 'feed-footer',
|
||||
title: feedInfo.displayName,
|
||||
uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`,
|
||||
})
|
||||
}
|
||||
if (!this.hasMore) {
|
||||
items.push({_reactKey: '__footer__', type: 'footer'})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
/**
|
||||
* Nuke all data
|
||||
*/
|
||||
clear() {
|
||||
this.rootStore.log.debug('MultiFeedModel:clear')
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.hasMore = true
|
||||
this.feeds = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any event listeners. Returns a cleanup function.
|
||||
*/
|
||||
registerListeners() {
|
||||
const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
|
||||
return () => sub.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and load
|
||||
*/
|
||||
async refresh() {
|
||||
this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds
|
||||
await this.loadMore(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more posts to the end of the feed
|
||||
*/
|
||||
loadMore = bundleAsync(async (isRefreshing: boolean = false) => {
|
||||
if (!isRefreshing && !this.hasMore) {
|
||||
return
|
||||
}
|
||||
if (isRefreshing) {
|
||||
this.isRefreshing = true // set optimistically for UI
|
||||
this.feeds = []
|
||||
}
|
||||
this._xLoading(isRefreshing)
|
||||
const start = this.feeds.length
|
||||
const newFeeds: PostsFeedModel[] = []
|
||||
for (
|
||||
let i = start;
|
||||
i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length;
|
||||
i++
|
||||
) {
|
||||
const feed = new PostsFeedModel(this.rootStore, 'custom', {
|
||||
feed: this.feedInfos[i].uri,
|
||||
})
|
||||
feed.pageSize = FEED_PAGE_SIZE
|
||||
await feed.setup()
|
||||
newFeeds.push(feed)
|
||||
}
|
||||
runInAction(() => {
|
||||
this.feeds = this.feeds.concat(newFeeds)
|
||||
this.hasMore = this.feeds.length < this.feedInfos.length
|
||||
})
|
||||
this._xIdle()
|
||||
})
|
||||
|
||||
/**
|
||||
* Attempt to load more again after a failure
|
||||
*/
|
||||
async retryLoadMore() {
|
||||
this.hasMore = true
|
||||
return this.loadMore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes posts from the feed upon deletion.
|
||||
*/
|
||||
onPostDeleted(uri: string) {
|
||||
for (const f of this.feeds) {
|
||||
f.onPostDeleted(uri)
|
||||
}
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
}
|
||||
|
||||
_xIdle() {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = true
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
}
|
|
@ -181,7 +181,7 @@ export class NotificationsFeedItemModel {
|
|||
return false
|
||||
}
|
||||
|
||||
get additionaDataUri(): string | undefined {
|
||||
get additionalDataUri(): string | undefined {
|
||||
if (this.isReply || this.isQuote || this.isMention) {
|
||||
return this.uri
|
||||
} else if (this.isLike || this.isRepost) {
|
||||
|
@ -290,7 +290,9 @@ export class NotificationsFeedModel {
|
|||
}
|
||||
|
||||
get hasNewLatest() {
|
||||
return this.queuedNotifications && this.queuedNotifications?.length > 0
|
||||
return Boolean(
|
||||
this.queuedNotifications && this.queuedNotifications?.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
get unreadCountLabel(): string {
|
||||
|
@ -490,7 +492,7 @@ export class NotificationsFeedModel {
|
|||
'mostRecent',
|
||||
res.data.notifications[0],
|
||||
)
|
||||
const addedUri = notif.additionaDataUri
|
||||
const addedUri = notif.additionalDataUri
|
||||
if (addedUri) {
|
||||
const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({
|
||||
uris: [addedUri],
|
||||
|
@ -583,7 +585,7 @@ export class NotificationsFeedModel {
|
|||
`item-${_idCounter++}`,
|
||||
item,
|
||||
)
|
||||
const uri = itemModel.additionaDataUri
|
||||
const uri = itemModel.additionalDataUri
|
||||
if (uri) {
|
||||
const models = addedPostMap.get(uri) || []
|
||||
models.push(itemModel)
|
||||
|
|
265
src/state/models/feeds/post.ts
Normal file
265
src/state/models/feeds/post.ts
Normal file
|
@ -0,0 +1,265 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
import {
|
||||
getEmbedLabels,
|
||||
getEmbedMuted,
|
||||
getEmbedMutedByList,
|
||||
getEmbedBlocking,
|
||||
getEmbedBlockedBy,
|
||||
getPostModeration,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
mergePostModerations,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
|
||||
let _idCounter = 0
|
||||
|
||||
export class PostsFeedItemModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
post: PostView
|
||||
postRecord?: AppBskyFeedPost.Record
|
||||
reply?: FeedViewPost['reply']
|
||||
reason?: FeedViewPost['reason']
|
||||
richText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: FeedViewPost,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
this.post = v.post
|
||||
if (AppBskyFeedPost.isRecord(this.post.record)) {
|
||||
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
valid.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
rootStore.log.warn(
|
||||
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
|
||||
this.post.record,
|
||||
)
|
||||
}
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
get rootUri(): string {
|
||||
if (this.reply?.root.uri) {
|
||||
return this.reply.root.uri
|
||||
}
|
||||
return this.post.uri
|
||||
}
|
||||
|
||||
get isThreadMuted() {
|
||||
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
return {
|
||||
postLabels: (this.post.labels || []).concat(
|
||||
getEmbedLabels(this.post.embed),
|
||||
),
|
||||
accountLabels: filterAccountLabels(this.post.author.labels),
|
||||
profileLabels: filterProfileLabels(this.post.author.labels),
|
||||
isMuted:
|
||||
this.post.author.viewer?.muted ||
|
||||
getEmbedMuted(this.post.embed) ||
|
||||
false,
|
||||
mutedByList:
|
||||
this.post.author.viewer?.mutedByList ||
|
||||
getEmbedMutedByList(this.post.embed),
|
||||
isBlocking:
|
||||
!!this.post.author.viewer?.blocking ||
|
||||
getEmbedBlocking(this.post.embed) ||
|
||||
false,
|
||||
isBlockedBy:
|
||||
!!this.post.author.viewer?.blockedBy ||
|
||||
getEmbedBlockedBy(this.post.embed) ||
|
||||
false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
copy(v: FeedViewPost) {
|
||||
this.post = v.post
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
}
|
||||
|
||||
copyMetrics(v: FeedViewPost) {
|
||||
this.post.replyCount = v.post.replyCount
|
||||
this.post.repostCount = v.post.repostCount
|
||||
this.post.likeCount = v.post.likeCount
|
||||
this.post.viewer = v.post.viewer
|
||||
}
|
||||
|
||||
get reasonRepost(): ReasonRepost | undefined {
|
||||
if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
|
||||
return this.reason as ReasonRepost
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
if (this.post.viewer.like) {
|
||||
const url = this.post.viewer.like
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) - 1
|
||||
this.post.viewer!.like = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteLike(url),
|
||||
)
|
||||
} else {
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) + 1
|
||||
this.post.viewer!.like = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.like(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.like = res.uri
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
if (this.post.viewer?.repost) {
|
||||
const url = this.post.viewer.repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) - 1
|
||||
this.post.viewer!.repost = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteRepost(url),
|
||||
)
|
||||
} else {
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) + 1
|
||||
this.post.viewer!.repost = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.repost(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.repost = res.uri
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleThreadMute() {
|
||||
if (this.isThreadMuted) {
|
||||
this.rootStore.mutedThreads.uris.delete(this.rootUri)
|
||||
} else {
|
||||
this.rootStore.mutedThreads.uris.add(this.rootUri)
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
}
|
||||
}
|
||||
|
||||
export class PostsFeedSliceModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
items: PostsFeedItemModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
slice: FeedViewPostsSlice,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
for (const item of slice.items) {
|
||||
this.items.push(
|
||||
new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
|
||||
)
|
||||
}
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
get uri() {
|
||||
if (this.isReply) {
|
||||
return this.items[1].post.uri
|
||||
}
|
||||
return this.items[0].post.uri
|
||||
}
|
||||
|
||||
get isThread() {
|
||||
return (
|
||||
this.items.length > 1 &&
|
||||
this.items.every(
|
||||
item => item.post.author.did === this.items[0].post.author.did,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return this.items.length > 1 && !this.isThread
|
||||
}
|
||||
|
||||
get rootItem() {
|
||||
if (this.isReply) {
|
||||
return this.items[1]
|
||||
}
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
get moderation() {
|
||||
return mergePostModerations(this.items.map(item => item.moderation))
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
isThreadParentAt(i: number) {
|
||||
if (this.items.length === 1) {
|
||||
return false
|
||||
}
|
||||
return i < this.items.length - 1
|
||||
}
|
||||
|
||||
isThreadChildAt(i: number) {
|
||||
if (this.items.length === 1) {
|
||||
return false
|
||||
}
|
||||
return i > 0
|
||||
}
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedGetTimeline as GetTimeline,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
RichText,
|
||||
jsonToLex,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
} from '@atproto/api'
|
||||
import AwaitLock from 'await-lock'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
|
@ -19,269 +16,11 @@ import {
|
|||
mergePosts,
|
||||
} from 'lib/api/build-suggested-posts'
|
||||
import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
|
||||
import {updateDataOptimistically} from 'lib/async/revertible'
|
||||
import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
|
||||
import {
|
||||
getEmbedLabels,
|
||||
getEmbedMuted,
|
||||
getEmbedMutedByList,
|
||||
getEmbedBlocking,
|
||||
getEmbedBlockedBy,
|
||||
getPostModeration,
|
||||
mergePostModerations,
|
||||
filterAccountLabels,
|
||||
filterProfileLabels,
|
||||
} from 'lib/labeling/helpers'
|
||||
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
type ReasonRepost = AppBskyFeedDefs.ReasonRepost
|
||||
type PostView = AppBskyFeedDefs.PostView
|
||||
import {PostsFeedSliceModel} from './post'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
let _idCounter = 0
|
||||
|
||||
export class PostsFeedItemModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
post: PostView
|
||||
postRecord?: AppBskyFeedPost.Record
|
||||
reply?: FeedViewPost['reply']
|
||||
reason?: FeedViewPost['reason']
|
||||
richText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: FeedViewPost,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
this.post = v.post
|
||||
if (AppBskyFeedPost.isRecord(this.post.record)) {
|
||||
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
valid.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.postRecord = undefined
|
||||
this.richText = undefined
|
||||
rootStore.log.warn(
|
||||
'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
|
||||
this.post.record,
|
||||
)
|
||||
}
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
get rootUri(): string {
|
||||
if (this.reply?.root.uri) {
|
||||
return this.reply.root.uri
|
||||
}
|
||||
return this.post.uri
|
||||
}
|
||||
|
||||
get isThreadMuted() {
|
||||
return this.rootStore.mutedThreads.uris.has(this.rootUri)
|
||||
}
|
||||
|
||||
get labelInfo(): PostLabelInfo {
|
||||
return {
|
||||
postLabels: (this.post.labels || []).concat(
|
||||
getEmbedLabels(this.post.embed),
|
||||
),
|
||||
accountLabels: filterAccountLabels(this.post.author.labels),
|
||||
profileLabels: filterProfileLabels(this.post.author.labels),
|
||||
isMuted:
|
||||
this.post.author.viewer?.muted ||
|
||||
getEmbedMuted(this.post.embed) ||
|
||||
false,
|
||||
mutedByList:
|
||||
this.post.author.viewer?.mutedByList ||
|
||||
getEmbedMutedByList(this.post.embed),
|
||||
isBlocking:
|
||||
!!this.post.author.viewer?.blocking ||
|
||||
getEmbedBlocking(this.post.embed) ||
|
||||
false,
|
||||
isBlockedBy:
|
||||
!!this.post.author.viewer?.blockedBy ||
|
||||
getEmbedBlockedBy(this.post.embed) ||
|
||||
false,
|
||||
}
|
||||
}
|
||||
|
||||
get moderation(): PostModeration {
|
||||
return getPostModeration(this.rootStore, this.labelInfo)
|
||||
}
|
||||
|
||||
copy(v: FeedViewPost) {
|
||||
this.post = v.post
|
||||
this.reply = v.reply
|
||||
this.reason = v.reason
|
||||
}
|
||||
|
||||
copyMetrics(v: FeedViewPost) {
|
||||
this.post.replyCount = v.post.replyCount
|
||||
this.post.repostCount = v.post.repostCount
|
||||
this.post.likeCount = v.post.likeCount
|
||||
this.post.viewer = v.post.viewer
|
||||
}
|
||||
|
||||
get reasonRepost(): ReasonRepost | undefined {
|
||||
if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') {
|
||||
return this.reason as ReasonRepost
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
if (this.post.viewer.like) {
|
||||
const url = this.post.viewer.like
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) - 1
|
||||
this.post.viewer!.like = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteLike(url),
|
||||
)
|
||||
} else {
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.likeCount = (this.post.likeCount || 0) + 1
|
||||
this.post.viewer!.like = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.like(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.like = res.uri
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
if (this.post.viewer?.repost) {
|
||||
const url = this.post.viewer.repost
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) - 1
|
||||
this.post.viewer!.repost = undefined
|
||||
},
|
||||
() => this.rootStore.agent.deleteRepost(url),
|
||||
)
|
||||
} else {
|
||||
await updateDataOptimistically(
|
||||
this.post,
|
||||
() => {
|
||||
this.post.repostCount = (this.post.repostCount || 0) + 1
|
||||
this.post.viewer!.repost = 'pending'
|
||||
},
|
||||
() => this.rootStore.agent.repost(this.post.uri, this.post.cid),
|
||||
res => {
|
||||
this.post.viewer!.repost = res.uri
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleThreadMute() {
|
||||
if (this.isThreadMuted) {
|
||||
this.rootStore.mutedThreads.uris.delete(this.rootUri)
|
||||
} else {
|
||||
this.rootStore.mutedThreads.uris.add(this.rootUri)
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
}
|
||||
}
|
||||
|
||||
export class PostsFeedSliceModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
|
||||
// data
|
||||
items: PostsFeedItemModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
slice: FeedViewPostsSlice,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
for (const item of slice.items) {
|
||||
this.items.push(
|
||||
new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item),
|
||||
)
|
||||
}
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
get uri() {
|
||||
if (this.isReply) {
|
||||
return this.items[1].post.uri
|
||||
}
|
||||
return this.items[0].post.uri
|
||||
}
|
||||
|
||||
get isThread() {
|
||||
return (
|
||||
this.items.length > 1 &&
|
||||
this.items.every(
|
||||
item => item.post.author.did === this.items[0].post.author.did,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get isReply() {
|
||||
return this.items.length > 1 && !this.isThread
|
||||
}
|
||||
|
||||
get rootItem() {
|
||||
if (this.isReply) {
|
||||
return this.items[1]
|
||||
}
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
get moderation() {
|
||||
return mergePostModerations(this.items.map(item => item.moderation))
|
||||
}
|
||||
|
||||
containsUri(uri: string) {
|
||||
return !!this.items.find(item => item.post.uri === uri)
|
||||
}
|
||||
|
||||
isThreadParentAt(i: number) {
|
||||
if (this.items.length === 1) {
|
||||
return false
|
||||
}
|
||||
return i < this.items.length - 1
|
||||
}
|
||||
|
||||
isThreadChildAt(i: number) {
|
||||
if (this.items.length === 1) {
|
||||
return false
|
||||
}
|
||||
return i > 0
|
||||
}
|
||||
}
|
||||
|
||||
export class PostsFeedModel {
|
||||
// state
|
||||
isLoading = false
|
||||
|
@ -297,6 +36,7 @@ export class PostsFeedModel {
|
|||
loadMoreCursor: string | undefined
|
||||
pollCursor: string | undefined
|
||||
tuner = new FeedTuner()
|
||||
pageSize = PAGE_SIZE
|
||||
|
||||
// used to linearize async modifications to state
|
||||
lock = new AwaitLock()
|
||||
|
@ -309,8 +49,11 @@ export class PostsFeedModel {
|
|||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
|
||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
|
||||
public feedType: 'home' | 'author' | 'suggested' | 'custom',
|
||||
params:
|
||||
| GetTimeline.QueryParams
|
||||
| GetAuthorFeed.QueryParams
|
||||
| GetCustomFeed.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
|
@ -387,10 +130,9 @@ export class PostsFeedModel {
|
|||
}
|
||||
|
||||
get feedTuners() {
|
||||
if (this.feedType === 'goodstuff') {
|
||||
if (this.feedType === 'custom') {
|
||||
return [
|
||||
FeedTuner.dedupReposts,
|
||||
FeedTuner.likedRepliesOnly,
|
||||
FeedTuner.preferredLangOnly(
|
||||
this.rootStore.preferences.contentLanguages,
|
||||
),
|
||||
|
@ -416,7 +158,7 @@ export class PostsFeedModel {
|
|||
this.tuner.reset()
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||
const res = await this._getFeed({limit: this.pageSize})
|
||||
await this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
|
@ -455,7 +197,7 @@ export class PostsFeedModel {
|
|||
try {
|
||||
const res = await this._getFeed({
|
||||
cursor: this.loadMoreCursor,
|
||||
limit: PAGE_SIZE,
|
||||
limit: this.pageSize,
|
||||
})
|
||||
await this._appendAll(res)
|
||||
this._xIdle()
|
||||
|
@ -524,7 +266,7 @@ export class PostsFeedModel {
|
|||
if (this.hasNewLatest || this.feedType === 'suggested') {
|
||||
return
|
||||
}
|
||||
const res = await this._getFeed({limit: PAGE_SIZE})
|
||||
const res = await this._getFeed({limit: this.pageSize})
|
||||
const tuner = new FeedTuner()
|
||||
const slices = tuner.tune(res.data.feed, this.feedTuners)
|
||||
this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri)
|
||||
|
@ -599,13 +341,15 @@ export class PostsFeedModel {
|
|||
// helper functions
|
||||
// =
|
||||
|
||||
async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
async _replaceAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
||||
) {
|
||||
this.pollCursor = res.data.feed[0]?.post.uri
|
||||
return this._appendAll(res, true)
|
||||
}
|
||||
|
||||
async _appendAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response,
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
||||
replace = false,
|
||||
) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
|
@ -644,7 +388,9 @@ export class PostsFeedModel {
|
|||
})
|
||||
}
|
||||
|
||||
_updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
|
||||
_updateAll(
|
||||
res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
|
||||
) {
|
||||
for (const item of res.data.feed) {
|
||||
const existingSlice = this.slices.find(slice =>
|
||||
slice.containsUri(item.post.uri),
|
||||
|
@ -661,8 +407,13 @@ export class PostsFeedModel {
|
|||
}
|
||||
|
||||
protected async _getFeed(
|
||||
params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
|
||||
): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
|
||||
params:
|
||||
| GetTimeline.QueryParams
|
||||
| GetAuthorFeed.QueryParams
|
||||
| GetCustomFeed.QueryParams,
|
||||
): Promise<
|
||||
GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
|
||||
> {
|
||||
params = Object.assign({}, this.params, params)
|
||||
if (this.feedType === 'suggested') {
|
||||
const responses = await getMultipleAuthorsPosts(
|
||||
|
@ -684,61 +435,31 @@ export class PostsFeedModel {
|
|||
}
|
||||
} else if (this.feedType === 'home') {
|
||||
return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
|
||||
} else if (this.feedType === 'goodstuff') {
|
||||
const res = await getGoodStuff(
|
||||
this.rootStore.session.currentSession?.accessJwt || '',
|
||||
params as GetTimeline.QueryParams,
|
||||
} else if (this.feedType === 'custom') {
|
||||
this.checkIfCustomFeedIsOnlineAndValid(
|
||||
params as GetCustomFeed.QueryParams,
|
||||
)
|
||||
res.data.feed = (res.data.feed || []).filter(
|
||||
item => !item.post.author.viewer?.muted,
|
||||
return this.rootStore.agent.app.bsky.feed.getFeed(
|
||||
params as GetCustomFeed.QueryParams,
|
||||
)
|
||||
return res
|
||||
} else {
|
||||
return this.rootStore.agent.getAuthorFeed(
|
||||
params as GetAuthorFeed.QueryParams,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HACK
|
||||
// temporary off-spec route to get the good stuff
|
||||
// -prf
|
||||
async function getGoodStuff(
|
||||
accessJwt: string,
|
||||
params: GetTimeline.QueryParams,
|
||||
): Promise<GetTimeline.Response> {
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), 15e3)
|
||||
|
||||
const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
|
||||
let k: keyof GetTimeline.QueryParams
|
||||
for (k in params) {
|
||||
if (typeof params[k] !== 'undefined') {
|
||||
uri.searchParams.set(k, String(params[k]))
|
||||
private async checkIfCustomFeedIsOnlineAndValid(
|
||||
params: GetCustomFeed.QueryParams,
|
||||
) {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
|
||||
feed: params.feed,
|
||||
})
|
||||
if (!res.data.isOnline || !res.data.isValid) {
|
||||
runInAction(() => {
|
||||
this.error =
|
||||
'This custom feed is not online or may be experiencing issues.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(String(uri), {
|
||||
method: 'get',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${accessJwt}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((value: string, key: string) => {
|
||||
resHeaders[key] = value
|
||||
})
|
||||
let resBody = await res.json()
|
||||
|
||||
clearTimeout(to)
|
||||
|
||||
return {
|
||||
success: res.status === 200,
|
||||
headers: resHeaders,
|
||||
data: jsonToLex(resBody),
|
||||
}
|
||||
}
|
||||
|
|
120
src/state/models/lists/actor-feeds.ts
Normal file
120
src/state/models/lists/actor-feeds.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
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'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
export class ActorFeedsModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
hasMore = true
|
||||
loadMoreCursor?: string
|
||||
|
||||
// data
|
||||
feeds: CustomFeedModel[] = []
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
public params: GetActorFeeds.QueryParams,
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async refresh() {
|
||||
return this.loadMore(true)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.isLoading = false
|
||||
this.isRefreshing = false
|
||||
this.hasLoaded = false
|
||||
this.error = ''
|
||||
this.hasMore = true
|
||||
this.loadMoreCursor = undefined
|
||||
this.feeds = []
|
||||
}
|
||||
|
||||
loadMore = bundleAsync(async (replace: boolean = false) => {
|
||||
if (!replace && !this.hasMore) {
|
||||
return
|
||||
}
|
||||
this._xLoading(replace)
|
||||
try {
|
||||
const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
|
||||
actor: this.params.actor,
|
||||
limit: PAGE_SIZE,
|
||||
cursor: replace ? undefined : this.loadMoreCursor,
|
||||
})
|
||||
if (replace) {
|
||||
this._replaceAll(res)
|
||||
} else {
|
||||
this._appendAll(res)
|
||||
}
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
})
|
||||
|
||||
// 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 followers', err)
|
||||
}
|
||||
}
|
||||
|
||||
// helper functions
|
||||
// =
|
||||
|
||||
_replaceAll(res: GetActorFeeds.Response) {
|
||||
this.feeds = []
|
||||
this._appendAll(res)
|
||||
}
|
||||
|
||||
_appendAll(res: GetActorFeeds.Response) {
|
||||
this.loadMoreCursor = res.data.cursor
|
||||
this.hasMore = !!this.loadMoreCursor
|
||||
for (const f of res.data.feeds) {
|
||||
this.feeds.push(new CustomFeedModel(this.rootStore, f))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ function genId(): string {
|
|||
|
||||
export class LogModel {
|
||||
entries: LogEntry[] = []
|
||||
timers = new Map<string, number>()
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
|
@ -74,6 +75,21 @@ export class LogModel {
|
|||
ts: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
time = (label = 'default') => {
|
||||
this.timers.set(label, performance.now())
|
||||
}
|
||||
|
||||
timeEnd = (label = 'default', warn = false) => {
|
||||
const endTime = performance.now()
|
||||
if (this.timers.has(label)) {
|
||||
const elapsedTime = endTime - this.timers.get(label)!
|
||||
console.log(`${label}: ${elapsedTime.toFixed(3)}ms`)
|
||||
this.timers.delete(label)
|
||||
} else {
|
||||
warn && console.warn(`Timer with label '${label}' does not exist.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function detailsToStr(details?: any) {
|
||||
|
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
@ -21,6 +22,7 @@ export class MeModel {
|
|||
followsCount: number | undefined
|
||||
followersCount: number | undefined
|
||||
mainFeed: PostsFeedModel
|
||||
savedFeeds: SavedFeedsModel
|
||||
notifications: NotificationsFeedModel
|
||||
follows: MyFollowsCache
|
||||
invites: ComAtprotoServerDefs.InviteCode[] = []
|
||||
|
@ -43,12 +45,14 @@ export class MeModel {
|
|||
})
|
||||
this.notifications = new NotificationsFeedModel(this.rootStore)
|
||||
this.follows = new MyFollowsCache(this.rootStore)
|
||||
this.savedFeeds = new SavedFeedsModel(this.rootStore)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.mainFeed.clear()
|
||||
this.notifications.clear()
|
||||
this.follows.clear()
|
||||
this.savedFeeds.clear()
|
||||
this.did = ''
|
||||
this.handle = ''
|
||||
this.displayName = ''
|
||||
|
@ -110,6 +114,7 @@ export class MeModel {
|
|||
/* dont await */ this.notifications.setup().catch(e => {
|
||||
this.rootStore.log.error('Failed to setup notifications model', e)
|
||||
})
|
||||
/* dont await */ this.savedFeeds.refresh(true)
|
||||
this.rootStore.emitSessionLoaded()
|
||||
await this.fetchInviteCodes()
|
||||
await this.fetchAppPasswords()
|
||||
|
@ -119,6 +124,7 @@ export class MeModel {
|
|||
}
|
||||
|
||||
async updateIfNeeded() {
|
||||
/* dont await */ this.savedFeeds.refresh(true)
|
||||
if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) {
|
||||
this.rootStore.log.debug('Updating me profile information')
|
||||
this.lastProfileStateUpdate = Date.now()
|
||||
|
|
|
@ -4,7 +4,6 @@ import {ImageModel} from './image'
|
|||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {openPicker} from 'lib/media/picker'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
export class GalleryModel {
|
||||
|
@ -24,13 +23,7 @@ export class GalleryModel {
|
|||
return this.images.length
|
||||
}
|
||||
|
||||
get paths() {
|
||||
return this.images.map(image =>
|
||||
image.compressed === undefined ? image.path : image.compressed.path,
|
||||
)
|
||||
}
|
||||
|
||||
async add(image_: RNImage) {
|
||||
async add(image_: Omit<RNImage, 'size'>) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
@ -39,15 +32,9 @@ export class GalleryModel {
|
|||
if (!this.images.some(i => i.path === image_.path)) {
|
||||
const image = new ImageModel(this.rootStore, image_)
|
||||
|
||||
if (!isNative) {
|
||||
await image.manipulate({})
|
||||
} else {
|
||||
await image.compress()
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.images.push(image)
|
||||
})
|
||||
// Initial resize
|
||||
image.manipulate({})
|
||||
this.images.push(image)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,11 +57,10 @@ export class GalleryModel {
|
|||
|
||||
const {width, height} = await getImageDim(uri)
|
||||
|
||||
const image: RNImage = {
|
||||
const image = {
|
||||
path: uri,
|
||||
height,
|
||||
width,
|
||||
size: getDataUriSize(uri),
|
||||
mime: 'image/jpeg',
|
||||
}
|
||||
|
||||
|
|
|
@ -3,14 +3,11 @@ import {RootStoreModel} from 'state/index'
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import * as ImageManipulator from 'expo-image-manipulator'
|
||||
import {getDataUriSize, scaleDownDimensions} from 'lib/media/util'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {compressAndResizeImageForPost} from 'lib/media/manip'
|
||||
|
||||
// TODO: EXIF embed
|
||||
// Cases to consider: ExternalEmbed
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
|
@ -21,17 +18,16 @@ export interface ImageManipulationAttributes {
|
|||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export class ImageModel implements RNImage {
|
||||
const MAX_IMAGE_SIZE_IN_BYTES = 976560
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
size: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
scaledWidth: number = POST_IMG_MAX.width
|
||||
scaledHeight: number = POST_IMG_MAX.height
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
|
@ -44,7 +40,7 @@ export class ImageModel implements RNImage {
|
|||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(public rootStore: RootStoreModel, image: RNImage) {
|
||||
constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) {
|
||||
makeAutoObservable(this, {
|
||||
rootStore: false,
|
||||
})
|
||||
|
@ -52,19 +48,8 @@ export class ImageModel implements RNImage {
|
|||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
this.size = image.size
|
||||
this.calcScaledDimensions()
|
||||
}
|
||||
|
||||
// TODO: Revisit compression factor due to updated sizing with zoom
|
||||
// get compressionFactor() {
|
||||
// const MAX_IMAGE_SIZE_IN_BYTES = 976560
|
||||
|
||||
// return this.size < MAX_IMAGE_SIZE_IN_BYTES
|
||||
// ? 1
|
||||
// : MAX_IMAGE_SIZE_IN_BYTES / this.size
|
||||
// }
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
@ -93,8 +78,24 @@ export class ImageModel implements RNImage {
|
|||
}
|
||||
}
|
||||
|
||||
getDisplayDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = '1:1',
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
@ -119,59 +120,70 @@ export class ImageModel implements RNImage {
|
|||
}
|
||||
}
|
||||
|
||||
calcScaledDimensions() {
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: this.width, height: this.height},
|
||||
POST_IMG_MAX,
|
||||
)
|
||||
this.scaledWidth = width
|
||||
this.scaledHeight = height
|
||||
}
|
||||
|
||||
async setAltText(altText: string) {
|
||||
this.altText = altText
|
||||
}
|
||||
|
||||
// Only for mobile
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
for (let i = 10; i > 0; i--) {
|
||||
// Float precision
|
||||
const factor = Math.round(i) / 10
|
||||
const compressed = await ImageManipulator.manipulateAsync(
|
||||
this.cropped?.path ?? this.path,
|
||||
undefined,
|
||||
{
|
||||
compress: factor,
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
if (compressed.base64 !== undefined) {
|
||||
const size = getDataUriSize(compressed.base64)
|
||||
|
||||
if (size < MAX_IMAGE_SIZE_IN_BYTES) {
|
||||
runInAction(() => {
|
||||
this.compressed = {
|
||||
mime: 'image/jpeg',
|
||||
path: compressed.uri,
|
||||
size,
|
||||
...compressed,
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compression fails when removing redundant information is not possible.
|
||||
// This can be tested with images that have high variance in noise.
|
||||
throw new Error('Failed to compress image')
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
try {
|
||||
const cropped = await openCropper({
|
||||
// openCropper requires an output width and height hence
|
||||
// getting upload dimensions before cropping is necessary.
|
||||
const {width, height} = this.getUploadDimensions({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
})
|
||||
|
||||
const cropped = await openCropper(this.rootStore, {
|
||||
mediaType: 'photo',
|
||||
path: this.path,
|
||||
freeStyleCropEnabled: true,
|
||||
width: this.scaledWidth,
|
||||
height: this.scaledHeight,
|
||||
})
|
||||
runInAction(() => {
|
||||
this.cropped = cropped
|
||||
this.compress()
|
||||
})
|
||||
} catch (err) {
|
||||
this.rootStore.log.error('Failed to crop photo', err)
|
||||
}
|
||||
}
|
||||
|
||||
async compress() {
|
||||
try {
|
||||
const {width, height} = scaleDownDimensions(
|
||||
this.cropped
|
||||
? {width: this.cropped.width, height: this.cropped.height}
|
||||
: {width: this.width, height: this.height},
|
||||
POST_IMG_MAX,
|
||||
)
|
||||
|
||||
// TODO: Revisit this - currently iOS uses this as well
|
||||
const compressed = await compressAndResizeImageForPost({
|
||||
...(this.cropped === undefined ? this : this.cropped),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.compressed = compressed
|
||||
this.cropped = cropped
|
||||
})
|
||||
} catch (err) {
|
||||
this.rootStore.log.error('Failed to compress photo', err)
|
||||
this.rootStore.log.error('Failed to crop photo', err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -181,6 +193,9 @@ export class ImageModel implements RNImage {
|
|||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
let uploadWidth: number | undefined
|
||||
let uploadHeight: number | undefined
|
||||
|
||||
const {aspectRatio, crop, position, scale} = attributes
|
||||
const modifiers = []
|
||||
|
||||
|
@ -197,14 +212,34 @@ export class ImageModel implements RNImage {
|
|||
}
|
||||
|
||||
if (crop !== undefined) {
|
||||
const croppedHeight = crop.height * this.height
|
||||
const croppedWidth = crop.width * this.width
|
||||
modifiers.push({
|
||||
crop: {
|
||||
originX: crop.originX * this.width,
|
||||
originY: crop.originY * this.height,
|
||||
height: crop.height * this.height,
|
||||
width: crop.width * this.width,
|
||||
height: croppedHeight,
|
||||
width: croppedWidth,
|
||||
},
|
||||
})
|
||||
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: croppedWidth, height: croppedHeight},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
} else {
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: this.width, height: this.height},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
}
|
||||
|
||||
if (scale !== undefined) {
|
||||
|
@ -222,36 +257,40 @@ export class ImageModel implements RNImage {
|
|||
const ratioMultiplier =
|
||||
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
|
||||
|
||||
const MAX_SIDE = 2000
|
||||
|
||||
const result = await ImageManipulator.manipulateAsync(
|
||||
this.path,
|
||||
[
|
||||
...modifiers,
|
||||
{resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
|
||||
{
|
||||
resize:
|
||||
ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
|
||||
},
|
||||
],
|
||||
{
|
||||
compress: 0.9,
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
runInAction(() => {
|
||||
this.compressed = {
|
||||
this.cropped = {
|
||||
mime: 'image/jpeg',
|
||||
path: result.uri,
|
||||
size: getDataUriSize(result.uri),
|
||||
size:
|
||||
result.base64 !== undefined
|
||||
? getDataUriSize(result.base64)
|
||||
: MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
|
||||
...result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resetCompressed() {
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.compressed = this.prev
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
|
|
|
@ -187,7 +187,7 @@ export class SessionModel {
|
|||
account => account.service === service && account.did === did,
|
||||
)
|
||||
|
||||
// fall back to any pre-existing access tokens
|
||||
// fall back to any preexisting access tokens
|
||||
let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt
|
||||
let accessJwt = session?.accessJwt || existingAccount?.accessJwt
|
||||
if (event === 'expired') {
|
||||
|
@ -247,7 +247,7 @@ export class SessionModel {
|
|||
const res = await agent.getProfile({actor: did}).catch(_e => undefined)
|
||||
if (res) {
|
||||
return {
|
||||
dispayName: res.data.displayName,
|
||||
displayName: res.data.displayName,
|
||||
aviUrl: res.data.avatar,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
src/state/models/ui/saved-feeds.ts
Normal file
185
src/state/models/ui/saved-feeds.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -119,7 +119,7 @@ export type Modal =
|
|||
// Moderation
|
||||
| ReportAccountModal
|
||||
| ReportPostModal
|
||||
| CreateMuteListModal
|
||||
| CreateOrEditMuteListModal
|
||||
| ListAddRemoveUserModal
|
||||
|
||||
// Posts
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue