diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts deleted file mode 100644 index 855b038c..00000000 --- a/src/state/models/content/post-thread-item.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyFeedPost as FeedPost, - AppBskyFeedDefs, - RichText, - PostModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {PostsFeedItemModel} from '../feeds/post' - -type PostView = AppBskyFeedDefs.PostView - -// NOTE: this model uses the same data as PostsFeedItemModel, but is used for -// rendering a single post in a thread view, and has additional state -// for rendering the thread view, but calls the same data methods -// as PostsFeedItemModel -// TODO: refactor as an extension or subclass of PostsFeedItemModel -export class PostThreadItemModel { - // ui state - _reactKey: string = '' - _depth = 0 - _isHighlightedPost = false - _showParentReplyLine = false - _showChildReplyLine = false - _hasMore = false - - // data - data: PostsFeedItemModel - post: PostView - postRecord?: FeedPost.Record - richText?: RichText - parent?: - | PostThreadItemModel - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost - replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[] - - constructor( - public rootStore: RootStoreModel, - v: AppBskyFeedDefs.ThreadViewPost, - ) { - this._reactKey = `thread-${v.post.uri}` - this.data = new PostsFeedItemModel(rootStore, this._reactKey, v) - this.post = this.data.post - this.postRecord = this.data.postRecord - this.richText = this.data.richText - // replies and parent are handled via assignTreeModels - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - return this.post.uri - } - - get parentUri() { - return this.postRecord?.reply?.parent.uri - } - - get rootUri(): string { - if (this.postRecord?.reply?.root.uri) { - return this.postRecord.reply.root.uri - } - return this.post.uri - } - - get moderation(): PostModeration { - return this.data.moderation - } - - assignTreeModels( - v: AppBskyFeedDefs.ThreadViewPost, - highlightedPostUri: string, - includeParent = true, - includeChildren = true, - ) { - // parents - if (includeParent && v.parent) { - if (AppBskyFeedDefs.isThreadViewPost(v.parent)) { - const parentModel = new PostThreadItemModel(this.rootStore, v.parent) - parentModel._depth = this._depth - 1 - parentModel._showChildReplyLine = true - if (v.parent.parent) { - parentModel._showParentReplyLine = true - parentModel.assignTreeModels( - v.parent, - highlightedPostUri, - true, - false, - ) - } - this.parent = parentModel - } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { - this.parent = v.parent - } else if (AppBskyFeedDefs.isBlockedPost(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, item) - itemModel._depth = this._depth + 1 - itemModel._showParentReplyLine = - itemModel.parentUri !== highlightedPostUri - if (item.replies?.length) { - itemModel._showChildReplyLine = true - itemModel.assignTreeModels(item, highlightedPostUri, false, true) - } - replies.push(itemModel) - } else if (AppBskyFeedDefs.isNotFoundPost(item)) { - replies.push(item) - } - } - this.replies = replies - } - } - - async toggleLike() { - this.data.toggleLike() - } - - async toggleRepost() { - this.data.toggleRepost() - } - - async delete() { - this.data.delete() - } -} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts deleted file mode 100644 index 65e74f7c..00000000 --- a/src/state/models/content/post-thread.ts +++ /dev/null @@ -1,342 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyFeedGetPostThread as GetPostThread, - AppBskyFeedDefs, - AppBskyFeedPost, - PostModeration, -} from '@atproto/api' -import {AtUri} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {ThreadViewPreference} from '../ui/preferences' -import {PostThreadItemModel} from './post-thread-item' -import {logger} from '#/logger' - -export class PostThreadModel { - // state - isLoading = false - isLoadingFromCache = false - isFromCache = false - isRefreshing = false - hasLoaded = false - error = '' - notFound = false - resolvedUri = '' - params: GetPostThread.QueryParams - - // data - thread?: PostThreadItemModel | null = null - isBlocked = false - - constructor( - public rootStore: RootStoreModel, - params: GetPostThread.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - static fromPostView( - rootStore: RootStoreModel, - postView: AppBskyFeedDefs.PostView, - ) { - const model = new PostThreadModel(rootStore, {uri: postView.uri}) - model.resolvedUri = postView.uri - model.hasLoaded = true - model.thread = new PostThreadItemModel(rootStore, { - post: postView, - }) - return model - } - - get hasContent() { - return !!this.thread - } - - get hasError() { - return this.error !== '' - } - - get rootUri(): string { - if (this.thread) { - if (this.thread.postRecord?.reply?.root.uri) { - return this.thread.postRecord.reply.root.uri - } - } - return this.resolvedUri - } - - get isCachedPostAReply() { - if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { - return !!this.thread?.post.record.reply - } - return false - } - - // public api - // = - - /** - * Load for first render - */ - async setup() { - if (!this.resolvedUri) { - await this._resolveUri() - } - - if (this.hasContent) { - await this.update() - } else { - const precache = this.rootStore.posts.cache.get(this.resolvedUri) - if (precache) { - await this._loadPrecached(precache) - } 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) { - logger.error('Failed to fetch post thread', {error: 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) { - runInAction(() => { - this.error = e.toString() - }) - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - async _loadPrecached(precache: AppBskyFeedDefs.PostView) { - // start with the cached version - this.isLoadingFromCache = true - this.isFromCache = true - this._replaceAll({ - success: true, - headers: {}, - data: { - thread: { - post: precache, - }, - }, - }) - this._xIdle() - - // then update in the background - try { - const res = await this.rootStore.agent.getPostThread( - Object.assign({}, this.params, {uri: this.resolvedUri}), - ) - this._replaceAll(res) - } catch (e: any) { - console.log(e) - this._xIdle(e) - } finally { - runInAction(() => { - this.isLoadingFromCache = false - }) - } - } - - async _load(isRefreshing = false) { - if (this.hasLoaded && !isRefreshing) { - return - } - 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) { - console.log(e) - this._xIdle(e) - } - } - - _replaceAll(res: GetPostThread.Response) { - this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread) - if (this.isBlocked) { - return - } - pruneReplies(res.data.thread) - const thread = new PostThreadItemModel( - this.rootStore, - res.data.thread as AppBskyFeedDefs.ThreadViewPost, - ) - thread._isHighlightedPost = true - thread.assignTreeModels( - res.data.thread as AppBskyFeedDefs.ThreadViewPost, - thread.uri, - ) - sortThread(thread, this.rootStore.preferences.thread) - this.thread = thread - } -} - -type MaybePost = - | AppBskyFeedDefs.ThreadViewPost - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost - | {[k: string]: unknown; $type: string} -function pruneReplies(post: MaybePost) { - if (post.replies) { - post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => { - if (reply.blocked) { - return false - } - pruneReplies(reply) - return true - }) - } -} - -type MaybeThreadItem = - | PostThreadItemModel - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost -function sortThread(item: MaybeThreadItem, opts: ThreadViewPreference) { - if ('notFound' in item) { - return - } - item = item as PostThreadItemModel - if (item.replies) { - item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => { - if ('notFound' in a && a.notFound) { - return 1 - } - if ('notFound' in b && b.notFound) { - return -1 - } - item = item as PostThreadItemModel - a = a as PostThreadItemModel - b = b as PostThreadItemModel - const aIsByOp = a.post.author.did === item.post.author.did - const bIsByOp = b.post.author.did === item.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 - } - // put moderated content down at the bottom - if (modScore(a.moderation) !== modScore(b.moderation)) { - return modScore(a.moderation) - modScore(b.moderation) - } - if (opts.prioritizeFollowedUsers) { - const af = a.post.author.viewer?.following - const bf = b.post.author.viewer?.following - if (af && !bf) { - return -1 - } else if (!af && bf) { - return 1 - } - } - if (opts.sort === 'oldest') { - return a.post.indexedAt.localeCompare(b.post.indexedAt) - } else if (opts.sort === 'newest') { - return b.post.indexedAt.localeCompare(a.post.indexedAt) - } else if (opts.sort === 'most-likes') { - if (a.post.likeCount === b.post.likeCount) { - return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest - } else { - return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes - } - } else if (opts.sort === 'random') { - return 0.5 - Math.random() // this is vaguely criminal but we can get away with it - } - return b.post.indexedAt.localeCompare(a.post.indexedAt) - }) - item.replies.forEach(reply => sortThread(reply, opts)) - } -} - -function modScore(mod: PostModeration): number { - if (mod.content.blur && mod.content.noOverride) { - return 5 - } - if (mod.content.blur) { - return 4 - } - if (mod.content.alert) { - return 3 - } - if (mod.embed.blur && mod.embed.noOverride) { - return 2 - } - if (mod.embed.blur) { - return 1 - } - return 0 -} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index a4c3517c..3a7fcf6c 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,7 +1,6 @@ import {makeAutoObservable} from 'mobx' import { LabelPreference as APILabelPreference, - BskyFeedViewPreference, BskyThreadViewPreference, } from '@atproto/api' import {isObj, hasProp} from 'lib/type-guards' @@ -10,9 +9,6 @@ import {ModerationOpts} from '@atproto/api' // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf export type LabelPreference = APILabelPreference | 'show' -export type FeedViewPreference = BskyFeedViewPreference & { - lab_mergeFeedEnabled?: boolean | undefined -} export type ThreadViewPreference = BskyThreadViewPreference & { lab_treeViewEnabled?: boolean | undefined } @@ -35,11 +31,6 @@ export class PreferencesModel { contentLabels = new LabelPreferencesModel() savedFeeds: string[] = [] pinnedFeeds: string[] = [] - thread: ThreadViewPreference = { - sort: 'oldest', - prioritizeFollowedUsers: true, - lab_treeViewEnabled: false, // experimental - } constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts index 386c7048..6ef41feb 100644 --- a/src/state/queries/post-thread.ts +++ b/src/state/queries/post-thread.ts @@ -4,8 +4,8 @@ import { AppBskyFeedGetPostThread, } from '@atproto/api' import {useQuery} from '@tanstack/react-query' -import {useSession} from '../session' -import {ThreadViewPreference} from '../models/ui/preferences' +import {useSession} from '#/state/session' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' export const RQKEY = (uri: string) => ['post-thread', uri] type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] @@ -72,7 +72,7 @@ export function usePostThreadQuery(uri: string | undefined) { export function sortThread( node: ThreadNode, - opts: ThreadViewPreference, + opts: UsePreferencesQueryResponse['threadViewPrefs'], ): ThreadNode { if (node.type !== 'post') { return node