Reorganize state models for clarity (#378)
This commit is contained in:
parent
9652d994dd
commit
2045c615a8
44 changed files with 163 additions and 171 deletions
372
src/state/models/content/post-thread.ts
Normal file
372
src/state/models/content/post-thread.ts
Normal file
|
@ -0,0 +1,372 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {
|
||||
AppBskyFeedGetPostThread as GetPostThread,
|
||||
AppBskyFeedPost as FeedPost,
|
||||
AppBskyFeedDefs,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '../../../third-party/uri'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
function* reactKeyGenerator(): Generator<string> {
|
||||
let counter = 0
|
||||
while (true) {
|
||||
yield `item-${counter++}`
|
||||
}
|
||||
}
|
||||
|
||||
export class PostThreadItemModel {
|
||||
// ui state
|
||||
_reactKey: string = ''
|
||||
_depth = 0
|
||||
_isHighlightedPost = false
|
||||
_showParentReplyLine = false
|
||||
_showChildReplyLine = false
|
||||
_hasMore = false
|
||||
|
||||
// data
|
||||
post: AppBskyFeedDefs.PostView
|
||||
postRecord?: FeedPost.Record
|
||||
parent?: PostThreadItemModel | AppBskyFeedDefs.NotFoundPost
|
||||
replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
|
||||
richText?: RichText
|
||||
|
||||
get uri() {
|
||||
return this.post.uri
|
||||
}
|
||||
|
||||
get parentUri() {
|
||||
return this.postRecord?.reply?.parent.uri
|
||||
}
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
reactKey: string,
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
) {
|
||||
this._reactKey = reactKey
|
||||
this.post = v.post
|
||||
if (FeedPost.isRecord(this.post.record)) {
|
||||
const valid = FeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(this.postRecord, {cleanNewlines: true})
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
valid.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'app.bsky.feed.getPostThread served an unexpected record type',
|
||||
this.post.record,
|
||||
)
|
||||
}
|
||||
// replies and parent are handled via assignTreeModels
|
||||
makeAutoObservable(this, {rootStore: false})
|
||||
}
|
||||
|
||||
assignTreeModels(
|
||||
keyGen: Generator<string>,
|
||||
v: AppBskyFeedDefs.ThreadViewPost,
|
||||
higlightedPostUri: string,
|
||||
includeParent = true,
|
||||
includeChildren = true,
|
||||
) {
|
||||
// parents
|
||||
if (includeParent && v.parent) {
|
||||
if (AppBskyFeedDefs.isThreadViewPost(v.parent)) {
|
||||
const parentModel = new PostThreadItemModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
v.parent,
|
||||
)
|
||||
parentModel._depth = this._depth - 1
|
||||
parentModel._showChildReplyLine = true
|
||||
if (v.parent.parent) {
|
||||
parentModel._showParentReplyLine = true //parentModel.uri !== higlightedPostUri
|
||||
parentModel.assignTreeModels(
|
||||
keyGen,
|
||||
v.parent,
|
||||
higlightedPostUri,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
}
|
||||
this.parent = parentModel
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
|
||||
this.parent = v.parent
|
||||
}
|
||||
}
|
||||
// replies
|
||||
if (includeChildren && v.replies) {
|
||||
const replies = []
|
||||
for (const item of v.replies) {
|
||||
if (AppBskyFeedDefs.isThreadViewPost(item)) {
|
||||
const itemModel = new PostThreadItemModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
item,
|
||||
)
|
||||
itemModel._depth = this._depth + 1
|
||||
itemModel._showParentReplyLine =
|
||||
itemModel.parentUri !== higlightedPostUri
|
||||
if (item.replies?.length) {
|
||||
itemModel._showChildReplyLine = true
|
||||
itemModel.assignTreeModels(
|
||||
keyGen,
|
||||
item,
|
||||
higlightedPostUri,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
}
|
||||
replies.push(itemModel)
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(item)) {
|
||||
replies.push(item)
|
||||
}
|
||||
}
|
||||
this.replies = replies
|
||||
}
|
||||
}
|
||||
|
||||
async toggleLike() {
|
||||
if (this.post.viewer?.like) {
|
||||
await this.rootStore.agent.deleteLike(this.post.viewer.like)
|
||||
runInAction(() => {
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount--
|
||||
this.post.viewer.like = undefined
|
||||
})
|
||||
} else {
|
||||
const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
|
||||
runInAction(() => {
|
||||
this.post.likeCount = this.post.likeCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.likeCount++
|
||||
this.post.viewer.like = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async toggleRepost() {
|
||||
if (this.post.viewer?.repost) {
|
||||
await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount--
|
||||
this.post.viewer.repost = undefined
|
||||
})
|
||||
} else {
|
||||
const res = await this.rootStore.agent.repost(
|
||||
this.post.uri,
|
||||
this.post.cid,
|
||||
)
|
||||
runInAction(() => {
|
||||
this.post.repostCount = this.post.repostCount || 0
|
||||
this.post.viewer = this.post.viewer || {}
|
||||
this.post.repostCount++
|
||||
this.post.viewer.repost = res.uri
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await this.rootStore.agent.deletePost(this.post.uri)
|
||||
this.rootStore.emitPostDeleted(this.post.uri)
|
||||
}
|
||||
}
|
||||
|
||||
export class PostThreadModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
notFound = false
|
||||
resolvedUri = ''
|
||||
params: GetPostThread.QueryParams
|
||||
|
||||
// data
|
||||
thread?: PostThreadItemModel
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: GetPostThread.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return typeof this.thread !== 'undefined'
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
/**
|
||||
* Load for first render
|
||||
*/
|
||||
async setup() {
|
||||
if (!this.resolvedUri) {
|
||||
await this._resolveUri()
|
||||
}
|
||||
if (this.hasContent) {
|
||||
await this.update()
|
||||
} else {
|
||||
await this._load()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
await this._load(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content in-place
|
||||
*/
|
||||
async update() {
|
||||
// NOTE: it currently seems that a full load-and-replace works fine for this
|
||||
// if the UI loses its place or has jarring re-arrangements, replace this
|
||||
// with a more in-place update
|
||||
this._load()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes when posts are deleted
|
||||
*/
|
||||
onPostDeleted(_uri: string) {
|
||||
this.refresh()
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading(isRefreshing = false) {
|
||||
this.isLoading = true
|
||||
this.isRefreshing = isRefreshing
|
||||
this.error = ''
|
||||
this.notFound = false
|
||||
}
|
||||
|
||||
_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 post thread', err)
|
||||
}
|
||||
this.notFound = err instanceof GetPostThread.NotFoundError
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
async _resolveUri() {
|
||||
const urip = new AtUri(this.params.uri)
|
||||
if (!urip.host.startsWith('did:')) {
|
||||
try {
|
||||
urip.host = await apilib.resolveName(this.rootStore, urip.host)
|
||||
} catch (e: any) {
|
||||
this.error = e.toString()
|
||||
}
|
||||
}
|
||||
runInAction(() => {
|
||||
this.resolvedUri = urip.toString()
|
||||
})
|
||||
}
|
||||
|
||||
async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.agent.getPostThread(
|
||||
Object.assign({}, this.params, {uri: this.resolvedUri}),
|
||||
)
|
||||
this._replaceAll(res)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
}
|
||||
|
||||
_replaceAll(res: GetPostThread.Response) {
|
||||
sortThread(res.data.thread)
|
||||
const keyGen = reactKeyGenerator()
|
||||
const thread = new PostThreadItemModel(
|
||||
this.rootStore,
|
||||
keyGen.next().value,
|
||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
)
|
||||
thread._isHighlightedPost = true
|
||||
thread.assignTreeModels(
|
||||
keyGen,
|
||||
res.data.thread as AppBskyFeedDefs.ThreadViewPost,
|
||||
thread.uri,
|
||||
)
|
||||
this.thread = thread
|
||||
}
|
||||
}
|
||||
|
||||
type MaybePost =
|
||||
| AppBskyFeedDefs.ThreadViewPost
|
||||
| AppBskyFeedDefs.NotFoundPost
|
||||
| {[k: string]: unknown; $type: string}
|
||||
function sortThread(post: MaybePost) {
|
||||
if (post.notFound) {
|
||||
return
|
||||
}
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (post.replies) {
|
||||
post.replies.sort((a: MaybePost, b: MaybePost) => {
|
||||
post = post as AppBskyFeedDefs.ThreadViewPost
|
||||
if (a.notFound) {
|
||||
return 1
|
||||
}
|
||||
if (b.notFound) {
|
||||
return -1
|
||||
}
|
||||
a = a as AppBskyFeedDefs.ThreadViewPost
|
||||
b = b as AppBskyFeedDefs.ThreadViewPost
|
||||
const aIsByOp = a.post.author.did === post.post.author.did
|
||||
const bIsByOp = b.post.author.did === post.post.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsByOp) {
|
||||
return -1 // op's own reply
|
||||
} else if (bIsByOp) {
|
||||
return 1 // op's own reply
|
||||
}
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
||||
})
|
||||
post.replies.forEach(reply => sortThread(reply))
|
||||
}
|
||||
}
|
103
src/state/models/content/post.ts
Normal file
103
src/state/models/content/post.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {AppBskyFeedPost as Post} from '@atproto/api'
|
||||
import {AtUri} from '../../../third-party/uri'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
type RemoveIndex<T> = {
|
||||
[P in keyof T as string extends P
|
||||
? never
|
||||
: number extends P
|
||||
? never
|
||||
: P]: T[P]
|
||||
}
|
||||
export class PostModel implements RemoveIndex<Post.Record> {
|
||||
// state
|
||||
isLoading = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
uri: string = ''
|
||||
|
||||
// data
|
||||
text: string = ''
|
||||
entities?: Post.Entity[]
|
||||
reply?: Post.ReplyRef
|
||||
createdAt: string = ''
|
||||
|
||||
constructor(public rootStore: RootStoreModel, uri: string) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
uri: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.uri = uri
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.createdAt !== ''
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._load()
|
||||
}
|
||||
|
||||
// state transitions
|
||||
// =
|
||||
|
||||
_xLoading() {
|
||||
this.isLoading = true
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
_xIdle(err?: any) {
|
||||
this.isLoading = false
|
||||
this.hasLoaded = true
|
||||
this.error = cleanError(err)
|
||||
if (err) {
|
||||
this.rootStore.log.error('Failed to fetch post', err)
|
||||
}
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
async _load() {
|
||||
this._xLoading()
|
||||
try {
|
||||
const urip = new AtUri(this.uri)
|
||||
const res = await this.rootStore.agent.getPost({
|
||||
repo: urip.host,
|
||||
rkey: urip.rkey,
|
||||
})
|
||||
// TODO
|
||||
// if (!res.valid) {
|
||||
// throw new Error(res.error)
|
||||
// }
|
||||
this._replaceAll(res.value)
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
}
|
||||
|
||||
_replaceAll(res: Post.Record) {
|
||||
this.text = res.text
|
||||
this.entities = res.entities
|
||||
this.reply = res.reply
|
||||
this.createdAt = res.createdAt
|
||||
}
|
||||
}
|
224
src/state/models/content/profile.ts
Normal file
224
src/state/models/content/profile.ts
Normal file
|
@ -0,0 +1,224 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {PickedMedia} from 'lib/media/picker'
|
||||
import {
|
||||
AppBskyActorGetProfile as GetProfile,
|
||||
AppBskyActorProfile,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {RootStoreModel} from '../root-store'
|
||||
import * as apilib from 'lib/api/index'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||
|
||||
export class ProfileViewerModel {
|
||||
muted?: boolean
|
||||
following?: string
|
||||
followedBy?: string
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
|
||||
export class ProfileModel {
|
||||
// state
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
hasLoaded = false
|
||||
error = ''
|
||||
params: GetProfile.QueryParams
|
||||
|
||||
// data
|
||||
did: string = ''
|
||||
handle: string = ''
|
||||
creator: string = ''
|
||||
displayName?: string = ''
|
||||
description?: string = ''
|
||||
avatar?: string = ''
|
||||
banner?: string = ''
|
||||
followersCount: number = 0
|
||||
followsCount: number = 0
|
||||
postsCount: number = 0
|
||||
viewer = new ProfileViewerModel()
|
||||
|
||||
// added data
|
||||
descriptionRichText?: RichText = new RichText({text: ''})
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
params: GetProfile.QueryParams,
|
||||
) {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
rootStore: false,
|
||||
params: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
)
|
||||
this.params = params
|
||||
}
|
||||
|
||||
get hasContent() {
|
||||
return this.did !== ''
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
return this.error !== ''
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.hasLoaded && !this.hasContent
|
||||
}
|
||||
|
||||
// public api
|
||||
// =
|
||||
|
||||
async setup() {
|
||||
await this._load()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this._load(true)
|
||||
}
|
||||
|
||||
async toggleFollowing() {
|
||||
if (!this.rootStore.me.did) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
|
||||
const follows = this.rootStore.me.follows
|
||||
const followUri = follows.isFollowing(this.did)
|
||||
? follows.getFollowUri(this.did)
|
||||
: undefined
|
||||
|
||||
// guard against this view getting out of sync with the follows cache
|
||||
if (followUri !== this.viewer.following) {
|
||||
this.viewer.following = followUri
|
||||
return
|
||||
}
|
||||
|
||||
if (followUri) {
|
||||
await this.rootStore.agent.deleteFollow(followUri)
|
||||
runInAction(() => {
|
||||
this.followersCount--
|
||||
this.viewer.following = undefined
|
||||
this.rootStore.me.follows.removeFollow(this.did)
|
||||
})
|
||||
} else {
|
||||
const res = await this.rootStore.agent.follow(this.did)
|
||||
runInAction(() => {
|
||||
this.followersCount++
|
||||
this.viewer.following = res.uri
|
||||
this.rootStore.me.follows.addFollow(this.did, res.uri)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
updates: AppBskyActorProfile.Record,
|
||||
newUserAvatar: PickedMedia | undefined | null,
|
||||
newUserBanner: PickedMedia | undefined | null,
|
||||
) {
|
||||
await this.rootStore.agent.upsertProfile(async existing => {
|
||||
existing = existing || {}
|
||||
existing.displayName = updates.displayName
|
||||
existing.description = updates.description
|
||||
if (newUserAvatar) {
|
||||
const res = await apilib.uploadBlob(
|
||||
this.rootStore,
|
||||
newUserAvatar.path,
|
||||
newUserAvatar.mime,
|
||||
)
|
||||
existing.avatar = res.data.blob
|
||||
} else if (newUserAvatar === null) {
|
||||
existing.avatar = undefined
|
||||
}
|
||||
if (newUserBanner) {
|
||||
const res = await apilib.uploadBlob(
|
||||
this.rootStore,
|
||||
newUserBanner.path,
|
||||
newUserBanner.mime,
|
||||
)
|
||||
existing.banner = res.data.blob
|
||||
} else if (newUserBanner === null) {
|
||||
existing.banner = undefined
|
||||
}
|
||||
return existing
|
||||
})
|
||||
await this.rootStore.me.load()
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async muteAccount() {
|
||||
await this.rootStore.agent.mute(this.did)
|
||||
this.viewer.muted = true
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
async unmuteAccount() {
|
||||
await this.rootStore.agent.unmute(this.did)
|
||||
this.viewer.muted = false
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
// 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 profile', err)
|
||||
}
|
||||
}
|
||||
|
||||
// loader functions
|
||||
// =
|
||||
|
||||
async _load(isRefreshing = false) {
|
||||
this._xLoading(isRefreshing)
|
||||
try {
|
||||
const res = await this.rootStore.agent.getProfile(this.params)
|
||||
this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
|
||||
this._replaceAll(res)
|
||||
await this._createRichText()
|
||||
this._xIdle()
|
||||
} catch (e: any) {
|
||||
this._xIdle(e)
|
||||
}
|
||||
}
|
||||
|
||||
_replaceAll(res: GetProfile.Response) {
|
||||
this.did = res.data.did
|
||||
this.handle = res.data.handle
|
||||
this.displayName = res.data.displayName
|
||||
this.description = res.data.description
|
||||
this.avatar = res.data.avatar
|
||||
this.banner = res.data.banner
|
||||
this.followersCount = res.data.followersCount || 0
|
||||
this.followsCount = res.data.followsCount || 0
|
||||
this.postsCount = res.data.postsCount || 0
|
||||
if (res.data.viewer) {
|
||||
Object.assign(this.viewer, res.data.viewer)
|
||||
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
|
||||
}
|
||||
}
|
||||
|
||||
async _createRichText() {
|
||||
this.descriptionRichText = new RichText(
|
||||
{text: this.description || ''},
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
await this.descriptionRichText.detectFacets(this.rootStore.agent)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue