diff options
Diffstat (limited to 'src/state/models/content')
-rw-r--r-- | src/state/models/content/feed-source.ts | 231 | ||||
-rw-r--r-- | src/state/models/content/list-membership.ts | 130 | ||||
-rw-r--r-- | src/state/models/content/list.ts | 508 | ||||
-rw-r--r-- | src/state/models/content/post-thread-item.ts | 139 | ||||
-rw-r--r-- | src/state/models/content/post-thread.ts | 354 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 306 |
6 files changed, 0 insertions, 1668 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts deleted file mode 100644 index 156e3be3b..000000000 --- a/src/state/models/content/feed-source.ts +++ /dev/null @@ -1,231 +0,0 @@ -import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api' -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from 'state/models/root-store' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class FeedSourceModel { - // state - _reactKey: string - hasLoaded = false - error: string | undefined - - // data - uri: string - cid: string = '' - type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported' - avatar: string | undefined = '' - displayName: string = '' - descriptionRT: RichText | null = null - creatorDid: string = '' - creatorHandle: string = '' - likeCount: number | undefined = 0 - likeUri: string | undefined = '' - - constructor(public rootStore: RootStoreModel, uri: string) { - this._reactKey = uri - this.uri = uri - - try { - const urip = new AtUri(uri) - if (urip.collection === 'app.bsky.feed.generator') { - this.type = 'feed-generator' - } else if (urip.collection === 'app.bsky.graph.list') { - this.type = 'list' - } - } catch {} - this.displayName = uri.split('/').pop() || '' - - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get href() { - const urip = new AtUri(this.uri) - const collection = - urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' - return `/profile/${urip.hostname}/${collection}/${urip.rkey}` - } - - get isSaved() { - return this.rootStore.preferences.savedFeeds.includes(this.uri) - } - - get isPinned() { - return this.rootStore.preferences.isPinnedFeed(this.uri) - } - - get isLiked() { - return !!this.likeUri - } - - get isOwner() { - return this.creatorDid === this.rootStore.me.did - } - - setup = bundleAsync(async () => { - try { - if (this.type === 'feed-generator') { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ - feed: this.uri, - }) - this.hydrateFeedGenerator(res.data.view) - } else if (this.type === 'list') { - const res = await this.rootStore.agent.app.bsky.graph.getList({ - list: this.uri, - limit: 1, - }) - this.hydrateList(res.data.list) - } - } catch (e) { - runInAction(() => { - this.error = cleanError(e) - }) - } - }) - - hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) { - this.uri = view.uri - this.cid = view.cid - this.avatar = view.avatar - this.displayName = view.displayName - ? sanitizeDisplayName(view.displayName) - : `Feed by ${sanitizeHandle(view.creator.handle, '@')}` - this.descriptionRT = new RichText({ - text: view.description || '', - facets: (view.descriptionFacets || [])?.slice(), - }) - this.creatorDid = view.creator.did - this.creatorHandle = view.creator.handle - this.likeCount = view.likeCount - this.likeUri = view.viewer?.like - this.hasLoaded = true - } - - hydrateList(view: AppBskyGraphDefs.ListView) { - this.uri = view.uri - this.cid = view.cid - this.avatar = view.avatar - this.displayName = view.name - ? sanitizeDisplayName(view.name) - : `User List by ${sanitizeHandle(view.creator.handle, '@')}` - this.descriptionRT = new RichText({ - text: view.description || '', - facets: (view.descriptionFacets || [])?.slice(), - }) - this.creatorDid = view.creator.did - this.creatorHandle = view.creator.handle - this.likeCount = undefined - this.hasLoaded = true - } - - async save() { - if (this.type !== 'feed-generator') { - return - } - try { - await this.rootStore.preferences.addSavedFeed(this.uri) - } catch (error) { - logger.error('Failed to save feed', {error}) - } finally { - track('CustomFeed:Save') - } - } - - async unsave() { - // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin - if (this.type !== 'feed-generator' && this.type !== 'list') { - return - } - try { - await this.rootStore.preferences.removeSavedFeed(this.uri) - } catch (error) { - logger.error('Failed to unsave feed', {error}) - } finally { - track('CustomFeed:Unsave') - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - logger.error('Failed to pin feed', {error}) - } finally { - track('CustomFeed:Pin', { - name: this.displayName, - uri: this.uri, - }) - } - } - - async togglePin() { - if (!this.isPinned) { - track('CustomFeed:Pin', { - name: this.displayName, - uri: this.uri, - }) - return this.rootStore.preferences.addPinnedFeed(this.uri) - } else { - track('CustomFeed:Unpin', { - name: this.displayName, - uri: this.uri, - }) - - if (this.type === 'list') { - // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin - return this.unsave() - } else { - return this.rootStore.preferences.removePinnedFeed(this.uri) - } - } - } - - async like() { - if (this.type !== 'feed-generator') { - return - } - try { - this.likeUri = 'pending' - this.likeCount = (this.likeCount || 0) + 1 - const res = await this.rootStore.agent.like(this.uri, this.cid) - this.likeUri = res.uri - } catch (e: any) { - this.likeUri = undefined - this.likeCount = (this.likeCount || 1) - 1 - logger.error('Failed to like feed', {error: e}) - } finally { - track('CustomFeed:Like') - } - } - - async unlike() { - if (this.type !== 'feed-generator') { - return - } - if (!this.likeUri) { - return - } - const uri = this.likeUri - try { - this.likeUri = undefined - this.likeCount = (this.likeCount || 1) - 1 - await this.rootStore.agent.deleteLike(uri!) - } catch (e: any) { - this.likeUri = uri - this.likeCount = (this.likeCount || 0) + 1 - logger.error('Failed to unlike feed', {error: e}) - } finally { - track('CustomFeed:Unlike') - } - } -} diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts deleted file mode 100644 index 135d34dd5..000000000 --- a/src/state/models/content/list-membership.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AtUri, AppBskyGraphListitem} from '@atproto/api' -import {runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' - -const PAGE_SIZE = 100 -interface Membership { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListMembershipModel { - // data - memberships: Membership[] = [] - - constructor(public rootStore: RootStoreModel, public subject: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async fetch() { - // NOTE - // this approach to determining list membership is too inefficient to work at any scale - // it needs to be replaced with server side list membership queries - // -prf - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.subject === this.subject), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - runInAction(() => { - this.memberships = records - }) - } - - getMembership(listUri: string) { - return this.memberships.find(m => m.value.list === listUri) - } - - isMember(listUri: string) { - return !!this.getMembership(listUri) - } - - async add(listUri: string) { - if (this.isMember(listUri)) { - return - } - const res = await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.subject, - list: listUri, - createdAt: new Date().toISOString(), - }, - ) - const {rkey} = new AtUri(res.uri) - const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.concat([record]) - }) - } - - async remove(listUri: string) { - const membership = this.getMembership(listUri) - if (!membership) { - return - } - const {rkey} = new AtUri(membership.uri) - await this.rootStore.agent.app.bsky.graph.listitem.delete({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.filter(m => m.value.list !== listUri) - }) - } - - async updateTo( - uris: string[], - ): Promise<{added: string[]; removed: string[]}> { - const added = [] - const removed = [] - for (const uri of uris) { - await this.add(uri) - added.push(uri) - } - for (const membership of this.memberships) { - if (!uris.includes(membership.value.list)) { - await this.remove(membership.value.list) - removed.push(membership.value.list) - } - } - return {added, removed} - } -} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts deleted file mode 100644 index fc09eeb9f..000000000 --- a/src/state/models/content/list.ts +++ /dev/null @@ -1,508 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - AppBskyActorDefs, - AppBskyGraphGetList as GetList, - AppBskyGraphDefs as GraphDefs, - AppBskyGraphList, - AppBskyGraphListitem, - RichText, -} from '@atproto/api' -import {Image as RNImage} from 'react-native-image-crop-picker' -import chunk from 'lodash.chunk' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {track} from 'lib/analytics/analytics' -import {until} from 'lib/async/until' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - // data - data: GraphDefs.ListView | null = null - items: GraphDefs.ListItemView[] = [] - descriptionRT: RichText | null = null - - static async createList( - rootStore: RootStoreModel, - { - purpose, - name, - description, - avatar, - }: { - purpose: string - name: string - description: string - avatar: RNImage | null | undefined - }, - ) { - if ( - purpose !== 'app.bsky.graph.defs#curatelist' && - purpose !== 'app.bsky.graph.defs#modlist' - ) { - throw new Error('Invalid list purpose: must be curatelist or modlist') - } - const record: AppBskyGraphList.Record = { - purpose, - name, - description, - avatar: undefined, - createdAt: new Date().toISOString(), - } - if (avatar) { - const blobRes = await apilib.uploadBlob( - rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } - const res = await rootStore.agent.app.bsky.graph.list.create( - { - repo: rootStore.me.did, - }, - record, - ) - - // wait for the appview to update - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - (v: GetList.Response, _e: any) => { - return typeof v?.data?.list.uri === 'string' - }, - () => - rootStore.agent.app.bsky.graph.getList({ - list: res.uri, - limit: 1, - }), - ) - return res - } - - constructor(public rootStore: RootStoreModel, public uri: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.items.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get isCuratelist() { - return this.data?.purpose === 'app.bsky.graph.defs#curatelist' - } - - get isModlist() { - return this.data?.purpose === 'app.bsky.graph.defs#modlist' - } - - get isOwner() { - return this.data?.creator.did === this.rootStore.me.did - } - - get isBlocking() { - return !!this.data?.viewer?.blocked - } - - get isMuting() { - return !!this.data?.viewer?.muted - } - - get isPinned() { - return this.rootStore.preferences.isPinnedFeed(this.uri) - } - - get creatorDid() { - return this.data?.creator.did - } - - getMembership(did: string) { - return this.items.find(item => item.subject.did === did) - } - - isMember(did: string) { - return !!this.getMembership(did) - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - await this._resolveUri() - const res = await this.rootStore.agent.app.bsky.graph.getList({ - list: this.uri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(replace ? e : undefined, !replace ? e : undefined) - } - }) - - async loadAll() { - for (let i = 0; i < 1000; i++) { - if (!this.hasMore) { - break - } - await this.loadMore() - } - } - - async updateMetadata({ - name, - description, - avatar, - }: { - name: string - description: string - avatar: RNImage | null | undefined - }) { - if (!this.data) { - return - } - if (!this.isOwner) { - throw new Error('Cannot edit this list') - } - await this._resolveUri() - - // get the current record - const {rkey} = new AtUri(this.uri) - const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ - repo: this.rootStore.me.did, - rkey, - }) - - // update the fields - record.name = name - record.description = description - if (avatar) { - const blobRes = await apilib.uploadBlob( - this.rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } else if (avatar === null) { - record.avatar = undefined - } - return await this.rootStore.agent.com.atproto.repo.putRecord({ - repo: this.rootStore.me.did, - collection: 'app.bsky.graph.list', - rkey, - record, - }) - } - - async delete() { - if (!this.data) { - return - } - await this._resolveUri() - - // fetch all the listitem records that belong to this list - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.list === this.uri), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - - // batch delete the list and listitem records - const createDel = (uri: string) => { - const urip = new AtUri(uri) - return { - $type: 'com.atproto.repo.applyWrites#delete', - collection: urip.collection, - rkey: urip.rkey, - } - } - const writes = records - .map(record => createDel(record.uri)) - .concat([createDel(this.uri)]) - - // apply in chunks - for (const writesChunk of chunk(writes, 10)) { - await this.rootStore.agent.com.atproto.repo.applyWrites({ - repo: this.rootStore.me.did, - writes: writesChunk, - }) - } - - /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri) - this.rootStore.emitListDeleted(this.uri) - } - - async addMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - return - } - await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: profile.did, - list: this.uri, - createdAt: new Date().toISOString(), - }, - ) - runInAction(() => { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - }) - } - - /** - * Just adds to local cache; used to reflect changes affected elsewhere - */ - cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (!this.isMember(profile.did)) { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - } - } - - /** - * Just removes from local cache; used to reflect changes affected elsewhere - */ - cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - this.items = this.items.filter(item => item.subject.did !== profile.did) - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - logger.error('Failed to pin feed', {error}) - } finally { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - } - } - - async togglePin() { - if (!this.isPinned) { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - return this.rootStore.preferences.addPinnedFeed(this.uri) - } else { - track('CustomFeed:Unpin', { - name: this.data?.name || '', - uri: this.uri, - }) - // TODO TEMPORARY - // lists are temporarily piggybacking on the saved/pinned feeds preferences - // we'll eventually replace saved feeds with the bookmarks API - // until then, we need to unsave lists instead of just unpin them - // -prf - // return this.rootStore.preferences.removePinnedFeed(this.uri) - return this.rootStore.preferences.removeSavedFeed(this.uri) - } - } - - async mute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.muteModList(this.data.uri) - track('Lists:Mute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: true}} - } - }) - } - - async unmute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.unmuteModList(this.data.uri) - track('Lists:Unmute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: false}} - } - }) - } - - async block() { - if (!this.data) { - return - } - await this._resolveUri() - const res = await this.rootStore.agent.blockModList(this.data.uri) - track('Lists:Block') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}} - } - }) - } - - async unblock() { - if (!this.data || !this.data.viewer?.blocked) { - return - } - await this._resolveUri() - await this.rootStore.agent.unblockModList(this.data.uri) - track('Lists:Unblock') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}} - } - }) - } - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any, loadMoreErr?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - this.loadMoreError = cleanError(loadMoreErr) - if (err) { - logger.error('Failed to fetch user items', {error: err}) - } - if (loadMoreErr) { - logger.error('Failed to fetch user items', { - error: loadMoreErr, - }) - } - } - - // helper functions - // = - - async _resolveUri() { - const urip = new AtUri(this.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.uri = urip.toString() - }) - } - - _replaceAll(res: GetList.Response) { - this.items = [] - this._appendAll(res) - } - - _appendAll(res: GetList.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.data = res.data.list - this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject.did})), - ) - if (this.data.description) { - this.descriptionRT = new RichText({ - text: this.data.description, - facets: (this.data.descriptionFacets || [])?.slice(), - }) - } else { - this.descriptionRT = null - } - } -} 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 942f3acc8..000000000 --- a/src/state/models/content/post-thread-item.ts +++ /dev/null @@ -1,139 +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 isThreadMuted() { - return this.data.isThreadMuted - } - - 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 toggleThreadMute() { - this.data.toggleThreadMute() - } - - 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 fd194056a..000000000 --- a/src/state/models/content/post-thread.ts +++ /dev/null @@ -1,354 +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 isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - 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() - } - - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - - // 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/content/profile.ts b/src/state/models/content/profile.ts deleted file mode 100644 index 14362ceec..000000000 --- a/src/state/models/content/profile.ts +++ /dev/null @@ -1,306 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - ComAtprotoLabelDefs, - AppBskyGraphDefs, - AppBskyActorGetProfile as GetProfile, - AppBskyActorProfile, - RichText, - moderateProfile, - ProfileModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {FollowState} from '../cache/my-follows' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class ProfileViewerModel { - muted?: boolean - mutedByList?: AppBskyGraphDefs.ListViewBasic - following?: string - followedBy?: string - blockedBy?: boolean - blocking?: string - blockingByList?: AppBskyGraphDefs.ListViewBasic; - [key: string]: unknown - - 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 - labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel(); - [key: string]: unknown - - // 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 - } - - get moderation(): ProfileModeration { - return moderateProfile(this, this.rootStore.preferences.moderationOpts) - } - - // public api - // = - - async setup() { - const precache = await this.rootStore.profiles.cache.get(this.params.actor) - if (precache) { - await this._loadWithCache(precache) - } else { - 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 = - (await follows.fetchFollowState(this.did)) === FollowState.Following - ? 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) { - // unfollow - await this.rootStore.agent.deleteFollow(followUri) - runInAction(() => { - this.followersCount-- - this.viewer.following = undefined - this.rootStore.me.follows.removeFollow(this.did) - }) - track('Profile:Unfollow', { - username: this.handle, - }) - } else { - // follow - const res = await this.rootStore.agent.follow(this.did) - runInAction(() => { - this.followersCount++ - this.viewer.following = res.uri - this.rootStore.me.follows.hydrate(this.did, this) - }) - track('Profile:Follow', { - username: this.handle, - }) - } - } - - async updateProfile( - updates: AppBskyActorProfile.Record, - newUserAvatar: RNImage | undefined | null, - newUserBanner: RNImage | 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() - } - - async blockAccount() { - const res = await this.rootStore.agent.app.bsky.graph.block.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.did, - createdAt: new Date().toISOString(), - }, - ) - this.viewer.blocking = res.uri - await this.refresh() - } - - async unblockAccount() { - if (!this.viewer.blocking) { - return - } - const {rkey} = new AtUri(this.viewer.blocking) - await this.rootStore.agent.app.bsky.graph.block.delete({ - repo: this.rootStore.me.did, - rkey, - }) - this.viewer.blocking = undefined - 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) { - logger.error('Failed to fetch profile', {error: 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) - if (res.data.handle) { - this.rootStore.handleResolutions.cache.set( - res.data.handle, - res.data.did, - ) - } - this._replaceAll(res) - await this._createRichText() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - async _loadWithCache(precache: GetProfile.Response) { - // use cached value - this._replaceAll(precache) - await this._createRichText() - this._xIdle() - - // fetch latest - 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() - } 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 - this.labels = res.data.labels - if (res.data.viewer) { - Object.assign(this.viewer, res.data.viewer) - } - this.rootStore.me.follows.hydrate(this.did, res.data) - } - - async _createRichText() { - this.descriptionRichText = new RichText( - {text: this.description || ''}, - {cleanNewlines: true}, - ) - await this.descriptionRichText.detectFacets(this.rootStore.agent) - } -} |