Reorganize state models for clarity (#378)

This commit is contained in:
Paul Frazee 2023-04-03 15:21:17 -05:00 committed by GitHub
parent 9652d994dd
commit 2045c615a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 163 additions and 171 deletions

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

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

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