Merge branch 'main' into inherit_system_theme

This commit is contained in:
Jaz 2023-05-30 18:25:29 -07:00 committed by GitHub
commit 09ade363fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
136 changed files with 5771 additions and 2428 deletions

View file

@ -16,6 +16,7 @@ export class ImageSizesCache {
if (Dimensions) {
return Dimensions
}
const prom =
this.activeRequests.get(uri) ||
new Promise<Dimensions>(resolve => {

View file

@ -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)) {

View 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))
}
}
}

View 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)
}
}

View 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
// =
}

View file

@ -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)

View 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
}
}

View file

@ -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),
}
}

View 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))
}
}
}

View file

@ -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) {

View file

@ -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()

View file

@ -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',
}

View file

@ -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
}
}

View file

@ -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,
}
}

View file

@ -11,6 +11,7 @@ import {
ALWAYS_FILTER_LABEL_GROUP,
ALWAYS_WARN_LABEL_GROUP,
} from 'lib/labeling/const'
import {DEFAULT_FEEDS} from 'lib/constants'
import {isIOS} from 'platform/detection'
const deviceLocales = getLocales()
@ -25,6 +26,7 @@ const LABEL_GROUPS = [
'spam',
'impersonation',
]
const VISIBILITY_VALUES = ['show', 'warn', 'hide']
export class LabelPreferencesModel {
nsfw: LabelPreference = 'hide'
@ -45,6 +47,8 @@ export class PreferencesModel {
contentLanguages: string[] =
deviceLocales?.map?.(locale => locale.languageCode) || []
contentLabels = new LabelPreferencesModel()
savedFeeds: string[] = []
pinnedFeeds: string[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {}, {autoBind: true})
@ -54,9 +58,16 @@ export class PreferencesModel {
return {
contentLanguages: this.contentLanguages,
contentLabels: this.contentLabels,
savedFeeds: this.savedFeeds,
pinnedFeeds: this.pinnedFeeds,
}
}
/**
* The function hydrates an object with properties related to content languages, labels, saved feeds,
* and pinned feeds that it gets from the parameter `v` (probably local storage)
* @param {unknown} v - the data object to hydrate from
*/
hydrate(v: unknown) {
if (isObj(v)) {
if (
@ -72,10 +83,29 @@ export class PreferencesModel {
// default to the device languages
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
}
if (
hasProp(v, 'savedFeeds') &&
Array.isArray(v.savedFeeds) &&
typeof v.savedFeeds.every(item => typeof item === 'string')
) {
this.savedFeeds = v.savedFeeds
}
if (
hasProp(v, 'pinnedFeeds') &&
Array.isArray(v.pinnedFeeds) &&
typeof v.pinnedFeeds.every(item => typeof item === 'string')
) {
this.pinnedFeeds = v.pinnedFeeds
}
}
}
/**
* This function fetches preferences and sets defaults for missing items.
*/
async sync() {
// fetch preferences
let hasSavedFeedsPref = false
const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
runInAction(() => {
for (const pref of res.data.preferences) {
@ -88,22 +118,83 @@ export class PreferencesModel {
AppBskyActorDefs.isContentLabelPref(pref) &&
AppBskyActorDefs.validateAdultContentPref(pref).success
) {
if (LABEL_GROUPS.includes(pref.label)) {
this.contentLabels[pref.label] = pref.visibility
if (
LABEL_GROUPS.includes(pref.label) &&
VISIBILITY_VALUES.includes(pref.visibility)
) {
this.contentLabels[pref.label as keyof LabelPreferencesModel] =
pref.visibility as LabelPreference
}
} else if (
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success
) {
this.savedFeeds = pref.saved
this.pinnedFeeds = pref.pinned
hasSavedFeedsPref = true
}
}
})
// set defaults on missing items
if (!hasSavedFeedsPref) {
const {saved, pinned} = await DEFAULT_FEEDS(
this.rootStore.agent.service.toString(),
(handle: string) =>
this.rootStore.agent
.resolveHandle({handle})
.then(({data}) => data.did),
)
runInAction(() => {
this.savedFeeds = saved
this.pinnedFeeds = pinned
})
res.data.preferences.push({
$type: 'app.bsky.actor.defs#savedFeedsPref',
saved,
pinned,
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: res.data.preferences,
})
/* dont await */ this.rootStore.me.savedFeeds.refresh()
}
}
async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
/**
* This function updates the preferences of a user and allows for a callback function to be executed
* before the update.
* @param cb - cb is a callback function that takes in a single parameter of type
* AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to
* update the preferences of the user. The function is called with the current preferences as an
* argument and if the callback returns false, the preferences are not updated.
* @returns void
*/
async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) {
const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
cb(res.data.preferences)
if (cb(res.data.preferences) === false) {
return
}
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: res.data.preferences,
})
}
/**
* This function resets the preferences to an empty array of no preferences.
*/
async reset() {
runInAction(() => {
this.contentLabels = new LabelPreferencesModel()
this.contentLanguages = deviceLocales.map(locale => locale.languageCode)
this.savedFeeds = []
this.pinnedFeeds = []
})
await this.rootStore.agent.app.bsky.actor.putPreferences({
preferences: [],
})
}
hasContentLanguage(code2: string) {
return this.contentLanguages.includes(code2)
}
@ -200,4 +291,62 @@ export class PreferencesModel {
}
return res
}
setFeeds(saved: string[], pinned: string[]) {
this.savedFeeds = saved
this.pinnedFeeds = pinned
}
async setSavedFeeds(saved: string[], pinned: string[]) {
const oldSaved = this.savedFeeds
const oldPinned = this.pinnedFeeds
this.setFeeds(saved, pinned)
try {
await this.update((prefs: AppBskyActorDefs.Preferences) => {
const existing = prefs.find(
pref =>
AppBskyActorDefs.isSavedFeedsPref(pref) &&
AppBskyActorDefs.validateSavedFeedsPref(pref).success,
)
if (existing) {
existing.saved = saved
existing.pinned = pinned
} else {
prefs.push({
$type: 'app.bsky.actor.defs#savedFeedsPref',
saved,
pinned,
})
}
})
} catch (e) {
runInAction(() => {
this.savedFeeds = oldSaved
this.pinnedFeeds = oldPinned
})
throw e
}
}
async addSavedFeed(v: string) {
return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds)
}
async removeSavedFeed(v: string) {
return this.setSavedFeeds(
this.savedFeeds.filter(uri => uri !== v),
this.pinnedFeeds.filter(uri => uri !== v),
)
}
async addPinnedFeed(v: string) {
return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v])
}
async removePinnedFeed(v: string) {
return this.setSavedFeeds(
this.savedFeeds,
this.pinnedFeeds.filter(uri => uri !== v),
)
}
}

View file

@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx'
import {RootStoreModel} from '../root-store'
import {ProfileModel} from '../content/profile'
import {PostsFeedModel} from '../feeds/posts'
import {ActorFeedsModel} from '../lists/actor-feeds'
import {ListsListModel} from '../lists/lists-list'
export enum Sections {
Posts = 'Posts',
PostsWithReplies = 'Posts & replies',
CustomAlgorithms = 'Feeds',
Lists = 'Lists',
}
const USER_SELECTOR_ITEMS = [
Sections.Posts,
Sections.PostsWithReplies,
Sections.Lists,
]
export interface ProfileUiParams {
user: string
}
@ -28,6 +24,7 @@ export class ProfileUiModel {
// data
profile: ProfileModel
feed: PostsFeedModel
algos: ActorFeedsModel
lists: ListsListModel
// ui state
@ -50,10 +47,11 @@ export class ProfileUiModel {
actor: params.user,
limit: 10,
})
this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
this.lists = new ListsListModel(rootStore, params.user)
}
get currentView(): PostsFeedModel | ListsListModel {
get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies
@ -62,6 +60,9 @@ export class ProfileUiModel {
} else if (this.selectedView === Sections.Lists) {
return this.lists
}
if (this.selectedView === Sections.CustomAlgorithms) {
return this.algos
}
throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
}
@ -75,7 +76,14 @@ export class ProfileUiModel {
}
get selectorItems() {
return USER_SELECTOR_ITEMS
const items = [Sections.Posts, Sections.PostsWithReplies]
if (this.algos.hasLoaded && !this.algos.isEmpty) {
items.push(Sections.CustomAlgorithms)
}
if (this.lists.hasLoaded && !this.lists.isEmpty) {
items.push(Sections.Lists)
}
return items
}
get selectedView() {
@ -84,9 +92,11 @@ export class ProfileUiModel {
get uiItems() {
let arr: any[] = []
// if loading, return loading item to show loading spinner
if (this.isInitialLoading) {
arr = arr.concat([ProfileUiModel.LOADING_ITEM])
} else if (this.currentView.hasError) {
// if error, return error item to show error message
arr = arr.concat([
{
_reactKey: '__error__',
@ -94,12 +104,16 @@ export class ProfileUiModel {
},
])
} else {
// not loading, no error, show content
if (
this.selectedView === Sections.Posts ||
this.selectedView === Sections.PostsWithReplies
this.selectedView === Sections.PostsWithReplies ||
this.selectedView === Sections.CustomAlgorithms
) {
if (this.feed.hasContent) {
if (this.selectedView === Sections.Posts) {
if (this.selectedView === Sections.CustomAlgorithms) {
arr = this.algos.feeds
} else if (this.selectedView === Sections.Posts) {
arr = this.feed.nonReplyFeed
} else {
arr = this.feed.slices.slice()
@ -117,6 +131,7 @@ export class ProfileUiModel {
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
} else {
// fallback, add empty item, to show empty message
arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
}
}
@ -151,6 +166,7 @@ export class ProfileUiModel {
.setup()
.catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
])
this.algos.refresh()
// HACK: need to use the DID as a param, not the username -prf
this.lists.source = this.profile.did
this.lists

View file

@ -0,0 +1,185 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from '../root-store'
import {bundleAsync} from 'lib/async/bundle'
import {cleanError} from 'lib/strings/errors'
import {CustomFeedModel} from '../feeds/custom-feed'
export class SavedFeedsModel {
// state
isLoading = false
isRefreshing = false
hasLoaded = false
error = ''
// data
feeds: CustomFeedModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(
this,
{
rootStore: false,
},
{autoBind: true},
)
}
get hasContent() {
return this.feeds.length > 0
}
get hasError() {
return this.error !== ''
}
get isEmpty() {
return this.hasLoaded && !this.hasContent
}
get pinned() {
return this.rootStore.preferences.pinnedFeeds
.map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel)
.filter(Boolean)
}
get unpinned() {
return this.feeds.filter(f => !this.isPinned(f))
}
get all() {
return this.pinned.concat(this.unpinned)
}
get pinnedFeedNames() {
return this.pinned.map(f => f.displayName)
}
// public api
// =
clear() {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = false
this.error = ''
this.feeds = []
}
refresh = bundleAsync(async (quietRefresh = false) => {
this._xLoading(!quietRefresh)
try {
let feeds: AppBskyFeedDefs.GeneratorView[] = []
for (
let i = 0;
i < this.rootStore.preferences.savedFeeds.length;
i += 25
) {
const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({
feeds: this.rootStore.preferences.savedFeeds.slice(i, 25),
})
feeds = feeds.concat(res.data.feeds)
}
runInAction(() => {
this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f))
})
this._xIdle()
} catch (e: any) {
this._xIdle(e)
}
})
async save(feed: CustomFeedModel) {
try {
await feed.save()
runInAction(() => {
this.feeds = [
...this.feeds,
new CustomFeedModel(this.rootStore, feed.data),
]
})
} catch (e: any) {
this.rootStore.log.error('Failed to save feed', e)
}
}
async unsave(feed: CustomFeedModel) {
const uri = feed.uri
try {
if (this.isPinned(feed)) {
await this.rootStore.preferences.removePinnedFeed(uri)
}
await feed.unsave()
runInAction(() => {
this.feeds = this.feeds.filter(f => f.data.uri !== uri)
})
} catch (e: any) {
this.rootStore.log.error('Failed to unsave feed', e)
}
}
async togglePinnedFeed(feed: CustomFeedModel) {
if (!this.isPinned(feed)) {
return this.rootStore.preferences.addPinnedFeed(feed.uri)
} else {
return this.rootStore.preferences.removePinnedFeed(feed.uri)
}
}
async reorderPinnedFeeds(feeds: CustomFeedModel[]) {
return this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri),
)
}
isPinned(feedOrUri: CustomFeedModel | string) {
let uri: string
if (typeof feedOrUri === 'string') {
uri = feedOrUri
} else {
uri = feedOrUri.uri
}
return this.rootStore.preferences.pinnedFeeds.includes(uri)
}
async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') {
const pinned = this.rootStore.preferences.pinnedFeeds.slice()
const index = pinned.indexOf(item.uri)
if (index === -1) {
return
}
if (direction === 'up' && index !== 0) {
const temp = pinned[index]
pinned[index] = pinned[index - 1]
pinned[index - 1] = temp
} else if (direction === 'down' && index < pinned.length - 1) {
const temp = pinned[index]
pinned[index] = pinned[index + 1]
pinned[index + 1] = temp
}
await this.rootStore.preferences.setSavedFeeds(
this.rootStore.preferences.savedFeeds,
pinned,
)
}
// state transitions
// =
_xLoading(isRefreshing = false) {
this.isLoading = true
this.isRefreshing = isRefreshing
this.error = ''
}
_xIdle(err?: any) {
this.isLoading = false
this.isRefreshing = false
this.hasLoaded = true
this.error = cleanError(err)
if (err) {
this.rootStore.log.error('Failed to fetch user feeds', err)
}
}
}

View file

@ -119,7 +119,7 @@ export type Modal =
// Moderation
| ReportAccountModal
| ReportPostModal
| CreateMuteListModal
| CreateOrEditMuteListModal
| ListAddRemoveUserModal
// Posts