diff options
Diffstat (limited to 'src/state/models/content')
-rw-r--r-- | src/state/models/content/post-thread.ts | 372 | ||||
-rw-r--r-- | src/state/models/content/post.ts | 103 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 224 |
3 files changed, 699 insertions, 0 deletions
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts new file mode 100644 index 000000000..031b82438 --- /dev/null +++ b/src/state/models/content/post-thread.ts @@ -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)) + } +} diff --git a/src/state/models/content/post.ts b/src/state/models/content/post.ts new file mode 100644 index 000000000..bf22ccf13 --- /dev/null +++ b/src/state/models/content/post.ts @@ -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 + } +} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts new file mode 100644 index 000000000..08616bf18 --- /dev/null +++ b/src/state/models/content/profile.ts @@ -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) + } +} |