From 2045c615a8f8a39ee9f54638a234f3d45f028399 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 3 Apr 2023 15:21:17 -0500 Subject: Reorganize state models for clarity (#378) --- src/lib/link-meta/bsky.ts | 4 +- src/lib/notifee.ts | 4 +- src/state/models/cache/profiles-view.ts | 52 ++ src/state/models/content/post-thread.ts | 372 ++++++++++++ src/state/models/content/post.ts | 103 ++++ src/state/models/content/profile.ts | 224 +++++++ src/state/models/discovery/suggested-posts.ts | 88 +++ src/state/models/discovery/user-autocomplete.ts | 103 ++++ src/state/models/feed-view.ts | 644 -------------------- src/state/models/feeds/notifications.ts | 574 ++++++++++++++++++ src/state/models/feeds/posts.ts | 648 +++++++++++++++++++++ src/state/models/likes-view.ts | 131 ----- src/state/models/lists/likes.ts | 131 +++++ src/state/models/lists/reposted-by.ts | 135 +++++ src/state/models/lists/user-followers.ts | 120 ++++ src/state/models/lists/user-follows.ts | 120 ++++ src/state/models/me.ts | 12 +- src/state/models/notifications-view.ts | 574 ------------------ src/state/models/post-thread-view.ts | 372 ------------ src/state/models/post.ts | 103 ---- src/state/models/profile-view.ts | 224 ------- src/state/models/profiles-view.ts | 52 -- src/state/models/reposted-by-view.ts | 135 ----- src/state/models/root-store.ts | 10 +- src/state/models/suggested-posts-view.ts | 88 --- src/state/models/ui/profile.ts | 16 +- src/state/models/ui/shell.ts | 6 +- src/state/models/user-autocomplete-view.ts | 103 ---- src/state/models/user-followers-view.ts | 120 ---- src/state/models/user-follows-view.ts | 120 ---- src/view/com/composer/Composer.tsx | 6 +- src/view/com/composer/text-input/TextInput.tsx | 4 +- src/view/com/composer/text-input/TextInput.web.tsx | 4 +- .../composer/text-input/mobile/Autocomplete.tsx | 4 +- .../com/composer/text-input/web/Autocomplete.tsx | 4 +- src/view/com/discover/SuggestedPosts.tsx | 6 +- src/view/com/modals/EditProfile.tsx | 4 +- src/view/com/notifications/Feed.tsx | 4 +- src/view/com/notifications/FeedItem.tsx | 8 +- src/view/com/post-thread/PostLikedBy.tsx | 7 +- src/view/com/post-thread/PostRepostedBy.tsx | 7 +- src/view/com/post-thread/PostThread.tsx | 18 +- src/view/com/post-thread/PostThreadItem.tsx | 4 +- src/view/com/post/Post.tsx | 8 +- src/view/com/post/PostText.tsx | 2 +- src/view/com/posts/Feed.tsx | 4 +- src/view/com/posts/FeedItem.tsx | 4 +- src/view/com/posts/FeedSlice.tsx | 6 +- src/view/com/profile/ProfileFollowers.tsx | 6 +- src/view/com/profile/ProfileFollows.tsx | 4 +- src/view/com/profile/ProfileHeader.tsx | 12 +- src/view/screens/Home.tsx | 6 +- src/view/screens/PostThread.tsx | 6 +- src/view/screens/Profile.tsx | 4 +- src/view/screens/Search.tsx | 6 +- src/view/shell/desktop/Search.tsx | 6 +- 56 files changed, 2767 insertions(+), 2775 deletions(-) create mode 100644 src/state/models/cache/profiles-view.ts create mode 100644 src/state/models/content/post-thread.ts create mode 100644 src/state/models/content/post.ts create mode 100644 src/state/models/content/profile.ts create mode 100644 src/state/models/discovery/suggested-posts.ts create mode 100644 src/state/models/discovery/user-autocomplete.ts delete mode 100644 src/state/models/feed-view.ts create mode 100644 src/state/models/feeds/notifications.ts create mode 100644 src/state/models/feeds/posts.ts delete mode 100644 src/state/models/likes-view.ts create mode 100644 src/state/models/lists/likes.ts create mode 100644 src/state/models/lists/reposted-by.ts create mode 100644 src/state/models/lists/user-followers.ts create mode 100644 src/state/models/lists/user-follows.ts delete mode 100644 src/state/models/notifications-view.ts delete mode 100644 src/state/models/post-thread-view.ts delete mode 100644 src/state/models/post.ts delete mode 100644 src/state/models/profile-view.ts delete mode 100644 src/state/models/profiles-view.ts delete mode 100644 src/state/models/reposted-by-view.ts delete mode 100644 src/state/models/suggested-posts-view.ts delete mode 100644 src/state/models/user-autocomplete-view.ts delete mode 100644 src/state/models/user-followers-view.ts delete mode 100644 src/state/models/user-follows-view.ts (limited to 'src') diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index 67ce3ad42..f4a96a22f 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -2,7 +2,7 @@ import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' import {RootStoreModel} from 'state/index' -import {PostThreadViewModel} from 'state/models/post-thread-view' +import {PostThreadModel} from 'state/models/content/post-thread' import {ComposerOptsQuote} from 'state/models/ui/shell' // TODO @@ -108,7 +108,7 @@ export async function getPostAsQuote( const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) - const threadView = new PostThreadViewModel(store, { + const threadView = new PostThreadModel(store, { uri: threadUri, depth: 0, }) diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts index 4b53ed724..d2e29c0a7 100644 --- a/src/lib/notifee.ts +++ b/src/lib/notifee.ts @@ -1,7 +1,7 @@ import notifee, {EventType} from '@notifee/react-native' import {AppBskyEmbedImages} from '@atproto/api' import {RootStoreModel} from 'state/models/root-store' -import {NotificationsViewItemModel} from 'state/models/notifications-view' +import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' import {enforceLen} from 'lib/strings/helpers' import {resetToTab} from '../Navigation' @@ -40,7 +40,7 @@ export function displayNotification( } export function displayNotificationFromModel( - notif: NotificationsViewItemModel, + notif: NotificationsFeedItemModel, ) { let author = notif.author.displayName || notif.author.handle let title: string diff --git a/src/state/models/cache/profiles-view.ts b/src/state/models/cache/profiles-view.ts new file mode 100644 index 000000000..b4bd70db5 --- /dev/null +++ b/src/state/models/cache/profiles-view.ts @@ -0,0 +1,52 @@ +import {makeAutoObservable} from 'mobx' +import {LRUMap} from 'lru_map' +import {RootStoreModel} from '../root-store' +import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' + +type CacheValue = Promise | GetProfile.Response +export class ProfilesCache { + cache: LRUMap = new LRUMap(100) + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + cache: false, + }, + {autoBind: true}, + ) + } + + // public api + // = + + async getProfile(did: string) { + const cached = this.cache.get(did) + if (cached) { + try { + return await cached + } catch (e) { + // ignore, we'll try again + } + } + try { + const promise = this.rootStore.agent.getProfile({ + actor: did, + }) + this.cache.set(did, promise) + const res = await promise + this.cache.set(did, res) + return res + } catch (e) { + this.cache.delete(did) + throw e + } + } + + overwrite(did: string, res: GetProfile.Response) { + if (this.cache.has(did)) { + this.cache.set(did, res) + } + } +} 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 { + 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, + 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 = { + [P in keyof T as string extends P + ? never + : number extends P + ? never + : P]: T[P] +} +export class PostModel implements RemoveIndex { + // 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) + } +} diff --git a/src/state/models/discovery/suggested-posts.ts b/src/state/models/discovery/suggested-posts.ts new file mode 100644 index 000000000..6c8de3023 --- /dev/null +++ b/src/state/models/discovery/suggested-posts.ts @@ -0,0 +1,88 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from '../root-store' +import {PostsFeedItemModel} from '../feeds/posts' +import {cleanError} from 'lib/strings/errors' +import {TEAM_HANDLES} from 'lib/constants' +import { + getMultipleAuthorsPosts, + mergePosts, +} from 'lib/api/build-suggested-posts' + +export class SuggestedPostsModel { + // state + isLoading = false + hasLoaded = false + error = '' + + // data + posts: PostsFeedItemModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.posts.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async setup() { + this._xLoading() + try { + const responses = await getMultipleAuthorsPosts( + this.rootStore, + TEAM_HANDLES(String(this.rootStore.agent.service)), + undefined, + 30, + ) + runInAction(() => { + const finalPosts = mergePosts(responses, {repostsOnly: true}) + // hydrate into models + this.posts = finalPosts.map((post, i) => { + // strip the reasons to hide that these are reposts + delete post.reason + return new PostsFeedItemModel(this.rootStore, `post-${i}`, post) + }) + }) + this._xIdle() + } catch (e: any) { + this.rootStore.log.error('SuggestedPostsView: Failed to load posts', { + e, + }) + this._xIdle() // dont bubble to the user + } + } + + // 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 suggested posts', err) + } + } +} diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts new file mode 100644 index 000000000..601e10ea0 --- /dev/null +++ b/src/state/models/discovery/user-autocomplete.ts @@ -0,0 +1,103 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyActorDefs} from '@atproto/api' +import AwaitLock from 'await-lock' +import {RootStoreModel} from '../root-store' + +export class UserAutocompleteModel { + // state + isLoading = false + isActive = false + prefix = '' + lock = new AwaitLock() + + // data + follows: AppBskyActorDefs.ProfileViewBasic[] = [] + searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] + knownHandles: Set = new Set() + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + knownHandles: false, + }, + {autoBind: true}, + ) + } + + get suggestions() { + if (!this.isActive) { + return [] + } + if (this.prefix) { + return this.searchRes.map(user => ({ + handle: user.handle, + displayName: user.displayName, + avatar: user.avatar, + })) + } + return this.follows.map(follow => ({ + handle: follow.handle, + displayName: follow.displayName, + avatar: follow.avatar, + })) + } + + // public api + // = + + async setup() { + await this._getFollows() + } + + setActive(v: boolean) { + this.isActive = v + } + + async setPrefix(prefix: string) { + const origPrefix = prefix.trim() + this.prefix = origPrefix + await this.lock.acquireAsync() + try { + if (this.prefix) { + if (this.prefix !== origPrefix) { + return // another prefix was set before we got our chance + } + await this._search() + } else { + this.searchRes = [] + } + } finally { + this.lock.release() + } + } + + // internal + // = + + async _getFollows() { + const res = await this.rootStore.agent.getFollows({ + actor: this.rootStore.me.did || '', + }) + runInAction(() => { + this.follows = res.data.follows + for (const f of this.follows) { + this.knownHandles.add(f.handle) + } + }) + } + + async _search() { + const res = await this.rootStore.agent.searchActorsTypeahead({ + term: this.prefix, + limit: 8, + }) + runInAction(() => { + this.searchRes = res.data.actors + for (const u of this.searchRes) { + this.knownHandles.add(u.handle) + } + }) + } +} diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts deleted file mode 100644 index 349723fbb..000000000 --- a/src/state/models/feed-view.ts +++ /dev/null @@ -1,644 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedGetAuthorFeed as GetAuthorFeed, - RichText, - jsonToLex, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import {bundleAsync} from 'lib/async/bundle' -import sampleSize from 'lodash.samplesize' -import {RootStoreModel} from './root-store' -import {cleanError} from 'lib/strings/errors' -import {SUGGESTED_FOLLOWS} from 'lib/constants' -import { - getCombinedCursors, - getMultipleAuthorsPosts, - mergePosts, -} from 'lib/api/build-suggested-posts' -import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' - -type FeedViewPost = AppBskyFeedDefs.FeedViewPost -type ReasonRepost = AppBskyFeedDefs.ReasonRepost -type PostView = AppBskyFeedDefs.PostView - -const PAGE_SIZE = 30 -let _idCounter = 0 - -export class FeedItemModel { - // ui state - _reactKey: string = '' - - // data - post: PostView - postRecord?: AppBskyFeedPost.Record - reply?: FeedViewPost['reply'] - reason?: FeedViewPost['reason'] - richText?: RichText - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: FeedViewPost, - ) { - this._reactKey = reactKey - this.post = v.post - if (AppBskyFeedPost.isRecord(this.post.record)) { - const valid = AppBskyFeedPost.validateRecord(this.post.record) - if (valid.success) { - this.postRecord = this.post.record - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'Received an invalid app.bsky.feed.post record', - valid.error, - ) - } - } else { - this.postRecord = undefined - this.richText = undefined - rootStore.log.warn( - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', - this.post.record, - ) - } - this.reply = v.reply - this.reason = v.reason - makeAutoObservable(this, {rootStore: false}) - } - - copy(v: FeedViewPost) { - this.post = v.post - this.reply = v.reply - this.reason = v.reason - } - - copyMetrics(v: FeedViewPost) { - this.post.replyCount = v.post.replyCount - this.post.repostCount = v.post.repostCount - this.post.likeCount = v.post.likeCount - this.post.viewer = v.post.viewer - } - - get reasonRepost(): ReasonRepost | undefined { - if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') { - return this.reason as ReasonRepost - } - } - - 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 FeedSliceModel { - // ui state - _reactKey: string = '' - - // data - items: FeedItemModel[] = [] - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - slice: FeedViewPostsSlice, - ) { - this._reactKey = reactKey - for (const item of slice.items) { - this.items.push( - new FeedItemModel(rootStore, `item-${_idCounter++}`, item), - ) - } - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - if (this.isReply) { - return this.items[1].post.uri - } - return this.items[0].post.uri - } - - get isThread() { - return ( - this.items.length > 1 && - this.items.every( - item => item.post.author.did === this.items[0].post.author.did, - ) - ) - } - - get isReply() { - return this.items.length > 1 && !this.isThread - } - - get rootItem() { - if (this.isReply) { - return this.items[1] - } - return this.items[0] - } - - containsUri(uri: string) { - return !!this.items.find(item => item.post.uri === uri) - } - - isThreadParentAt(i: number) { - if (this.items.length === 1) { - return false - } - return i < this.items.length - 1 - } - - isThreadChildAt(i: number) { - if (this.items.length === 1) { - return false - } - return i > 0 - } -} - -export class FeedModel { - // state - isLoading = false - isRefreshing = false - hasNewLatest = false - hasLoaded = false - error = '' - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams - hasMore = true - loadMoreCursor: string | undefined - pollCursor: string | undefined - tuner = new FeedTuner() - - // used to linearize async modifications to state - lock = new AwaitLock() - - // data - slices: FeedSliceModel[] = [] - nextSlices: FeedSliceModel[] = [] - - constructor( - public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - loadMoreCursor: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.slices.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get nonReplyFeed() { - if (this.feedType === 'author') { - return this.slices.filter(slice => { - const params = this.params as GetAuthorFeed.QueryParams - const item = slice.rootItem - const isRepost = - item?.reasonRepost?.by?.handle === params.actor || - item?.reasonRepost?.by?.did === params.actor - return ( - !item.reply || // not a reply - isRepost || // but allow if it's a repost - (slice.isThread && // or a thread by the user - item.reply?.root.author.did === item.post.author.did) - ) - }) - } else { - return this.slices - } - } - - setHasNewLatest(v: boolean) { - this.hasNewLatest = v - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - this.rootStore.log.debug('FeedModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasNewLatest = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.pollCursor = undefined - this.slices = [] - this.nextSlices = [] - this.tuner.reset() - } - - switchFeedType(feedType: 'home' | 'suggested') { - if (this.feedType === feedType) { - return - } - this.feedType = feedType - return this.setup() - } - - get feedTuners() { - if (this.feedType === 'goodstuff') { - return [ - FeedTuner.dedupReposts, - FeedTuner.likedRepliesOnly, - FeedTuner.preferredLangOnly( - this.rootStore.preferences.contentLanguages, - ), - ] - } - if (this.feedType === 'home') { - return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] - } - return [] - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - this.rootStore.log.debug('FeedModel:setup', {isRefreshing}) - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - } - await this.lock.acquireAsync() - try { - this.setHasNewLatest(false) - this.tuner.reset() - this._xLoading(isRefreshing) - try { - const res = await this._getFeed({limit: PAGE_SIZE}) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * 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.setup(true) - } - - /** - * Load more posts to the end of the feed - */ - loadMore = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.hasMore || this.hasError) { - return - } - this._xLoading() - try { - const res = await this._getFeed({ - cursor: this.loadMoreCursor, - limit: PAGE_SIZE, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to load more', { - params: this.params, - e, - }) - this.hasMore = false - } - } finally { - this.lock.release() - } - }) - - /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.slices.length) { - return - } - this._xLoading() - let numToFetch = this.slices.length - let cursor - try { - do { - const res: GetTimeline.Response = await this._getFeed({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.feed.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.feed.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Check if new posts are available - */ - async checkForLatest({autoPrepend}: {autoPrepend?: boolean} = {}) { - if (this.hasNewLatest || this.feedType === 'suggested') { - return - } - const res = await this._getFeed({limit: PAGE_SIZE}) - const tuner = new FeedTuner() - const nextSlices = tuner.tune(res.data.feed, this.feedTuners) - if (nextSlices[0]?.uri !== this.slices[0]?.uri) { - const nextSlicesModels = nextSlices.map( - slice => - new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice), - ) - if (autoPrepend) { - runInAction(() => { - this.slices = nextSlicesModels.concat( - this.slices.filter(slice1 => - nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), - ), - ) - this.setHasNewLatest(false) - }) - } else { - runInAction(() => { - this.nextSlices = nextSlicesModels - }) - this.setHasNewLatest(true) - } - } else { - this.setHasNewLatest(false) - } - } - - /** - * Sets the current slices to the "next slices" loaded by checkForLatest - */ - resetToLatest() { - if (this.nextSlices.length) { - this.slices = this.nextSlices - } - this.setHasNewLatest(false) - } - - /** - * Removes posts from the feed upon deletion. - */ - onPostDeleted(uri: string) { - let i - do { - i = this.slices.findIndex(slice => slice.containsUri(uri)) - if (i !== -1) { - this.slices.splice(i, 1) - } - } while (i !== -1) - } - - // 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('Posts feed request failed', err) - } - } - - // helper functions - // = - - async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { - this.pollCursor = res.data.feed[0]?.post.uri - return this._appendAll(res, true) - } - - async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response, - replace = false, - ) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - - const slices = this.tuner.tune(res.data.feed, this.feedTuners) - - const toAppend: FeedSliceModel[] = [] - for (const slice of slices) { - const sliceModel = new FeedSliceModel( - this.rootStore, - `item-${_idCounter++}`, - slice, - ) - toAppend.push(sliceModel) - } - runInAction(() => { - if (replace) { - this.slices = toAppend - } else { - this.slices = this.slices.concat(toAppend) - } - }) - } - - _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { - for (const item of res.data.feed) { - const existingSlice = this.slices.find(slice => - slice.containsUri(item.post.uri), - ) - if (existingSlice) { - const existingItem = existingSlice.items.find( - item2 => item2.post.uri === item.post.uri, - ) - if (existingItem) { - existingItem.copyMetrics(item) - } - } - } - } - - protected async _getFeed( - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, - ): Promise { - params = Object.assign({}, this.params, params) - if (this.feedType === 'suggested') { - const responses = await getMultipleAuthorsPosts( - this.rootStore, - sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), - params.cursor, - 20, - ) - const combinedCursor = getCombinedCursors(responses) - const finalData = mergePosts(responses, {bestOfOnly: true}) - const lastHeaders = responses[responses.length - 1].headers - return { - success: true, - data: { - feed: finalData, - cursor: combinedCursor, - }, - headers: lastHeaders, - } - } else if (this.feedType === 'home') { - return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) - } else if (this.feedType === 'goodstuff') { - const res = await getGoodStuff( - this.rootStore.session.currentSession?.accessJwt || '', - params as GetTimeline.QueryParams, - ) - res.data.feed = (res.data.feed || []).filter( - item => !item.post.author.viewer?.muted, - ) - return res - } else { - return this.rootStore.agent.getAuthorFeed( - params as GetAuthorFeed.QueryParams, - ) - } - } -} - -// HACK -// temporary off-spec route to get the good stuff -// -prf -async function getGoodStuff( - accessJwt: string, - params: GetTimeline.QueryParams, -): Promise { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), 15e3) - - const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') - let k: keyof GetTimeline.QueryParams - for (k in params) { - if (typeof params[k] !== 'undefined') { - uri.searchParams.set(k, String(params[k])) - } - } - - const res = await fetch(String(uri), { - method: 'get', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessJwt}`, - }, - signal: controller.signal, - }) - - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - let resBody = await res.json() - - clearTimeout(to) - - return { - success: res.status === 200, - headers: resHeaders, - data: jsonToLex(resBody), - } -} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts new file mode 100644 index 000000000..ea3538438 --- /dev/null +++ b/src/state/models/feeds/notifications.ts @@ -0,0 +1,574 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import { + AppBskyNotificationListNotifications as ListNotifications, + AppBskyActorDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, + AppBskyGraphFollow, +} from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from 'lib/async/bundle' +import {RootStoreModel} from '../root-store' +import {PostThreadModel} from '../content/post-thread' +import {cleanError} from 'lib/strings/errors' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +const PAGE_SIZE = 30 +const MS_1HR = 1e3 * 60 * 60 +const MS_2DAY = MS_1HR * 48 + +let _idCounter = 0 + +export interface GroupedNotification extends ListNotifications.Notification { + additional?: ListNotifications.Notification[] +} + +type SupportedRecord = + | AppBskyFeedPost.Record + | AppBskyFeedRepost.Record + | AppBskyFeedLike.Record + | AppBskyGraphFollow.Record + +export class NotificationsFeedItemModel { + // ui state + _reactKey: string = '' + + // data + uri: string = '' + cid: string = '' + author: AppBskyActorDefs.ProfileViewBasic = { + did: '', + handle: '', + avatar: '', + } + reason: string = '' + reasonSubject?: string + record?: SupportedRecord + isRead: boolean = false + indexedAt: string = '' + additional?: NotificationsFeedItemModel[] + + // additional data + additionalPost?: PostThreadModel + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: GroupedNotification, + ) { + makeAutoObservable(this, {rootStore: false}) + this._reactKey = reactKey + this.copy(v) + } + + copy(v: GroupedNotification, preserve = false) { + this.uri = v.uri + this.cid = v.cid + this.author = v.author + this.reason = v.reason + this.reasonSubject = v.reasonSubject + this.record = this.toSupportedRecord(v.record) + this.isRead = v.isRead + this.indexedAt = v.indexedAt + if (v.additional?.length) { + this.additional = [] + for (const add of v.additional) { + this.additional.push( + new NotificationsFeedItemModel(this.rootStore, '', add), + ) + } + } else if (!preserve) { + this.additional = undefined + } + } + + get isLike() { + return this.reason === 'like' + } + + get isRepost() { + return this.reason === 'repost' + } + + get isMention() { + return this.reason === 'mention' + } + + get isReply() { + return this.reason === 'reply' + } + + get isQuote() { + return this.reason === 'quote' + } + + get isFollow() { + return this.reason === 'follow' + } + + get needsAdditionalData() { + if ( + this.isLike || + this.isRepost || + this.isReply || + this.isQuote || + this.isMention + ) { + return !this.additionalPost + } + return false + } + + get subjectUri(): string { + if (this.reasonSubject) { + return this.reasonSubject + } + const record = this.record + if ( + AppBskyFeedRepost.isRecord(record) || + AppBskyFeedLike.isRecord(record) + ) { + return record.subject.uri + } + return '' + } + + toSupportedRecord(v: unknown): SupportedRecord | undefined { + for (const ns of [ + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, + AppBskyGraphFollow, + ]) { + if (ns.isRecord(v)) { + const valid = ns.validateRecord(v) + if (valid.success) { + return v + } else { + this.rootStore.log.warn('Received an invalid record', { + record: v, + error: valid.error, + }) + return + } + } + } + this.rootStore.log.warn( + 'app.bsky.notifications.list served an unsupported record type', + v, + ) + } + + async fetchAdditionalData() { + if (!this.needsAdditionalData) { + return + } + let postUri + if (this.isReply || this.isQuote || this.isMention) { + postUri = this.uri + } else if (this.isLike || this.isRepost) { + postUri = this.subjectUri + } + if (postUri) { + this.additionalPost = new PostThreadModel(this.rootStore, { + uri: postUri, + depth: 0, + }) + await this.additionalPost.setup().catch(e => { + this.rootStore.log.error( + 'Failed to load post needed by notification', + e, + ) + }) + } + } +} + +export class NotificationsFeedModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + params: ListNotifications.QueryParams + hasMore = true + loadMoreCursor?: string + + // used to linearize async modifications to state + lock = new AwaitLock() + + // data + notifications: NotificationsFeedItemModel[] = [] + unreadCount = 0 + + // this is used to help trigger push notifications + mostRecentNotificationUri: string | undefined + + constructor( + public rootStore: RootStoreModel, + params: ListNotifications.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + mostRecentNotificationUri: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.notifications.length !== 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('NotificationsModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.notifications = [] + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) + this.mostRecentNotificationUri = undefined + } + + /** + * Load for first render + */ + setup = bundleAsync(async (isRefreshing: boolean = false) => { + this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) + if (isRefreshing) { + this.isRefreshing = true // set optimistically for UI + } + await this.lock.acquireAsync() + try { + this._xLoading(isRefreshing) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + }) + const res = await this.rootStore.agent.listNotifications(params) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + } finally { + this.lock.release() + } + }) + + /** + * Reset and load + */ + async refresh() { + return this.setup(true) + } + + /** + * Load more posts to the end of the notifications + */ + loadMore = bundleAsync(async () => { + if (!this.hasMore) { + return + } + this.lock.acquireAsync() + try { + this._xLoading() + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + cursor: this.loadMoreCursor, + }) + const res = await this.rootStore.agent.listNotifications(params) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load more', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + + /** + * Load more posts at the start of the notifications + */ + loadLatest = bundleAsync(async () => { + if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { + return this.refresh() + } + this.lock.acquireAsync() + try { + this._xLoading() + try { + const res = await this.rootStore.agent.listNotifications({ + limit: PAGE_SIZE, + }) + await this._prependAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load latest', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + + /** + * Update content in-place + */ + update = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.notifications.length) { + return + } + this._xLoading() + let numToFetch = this.notifications.length + let cursor + try { + do { + const res: ListNotifications.Response = + await this.rootStore.agent.listNotifications({ + cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.notifications.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.notifications.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to update', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + + // unread notification apis + // = + + /** + * Get the current number of unread notifications + * returns true if the number changed + */ + loadUnreadCount = bundleAsync(async () => { + const old = this.unreadCount + const res = await this.rootStore.agent.countUnreadNotifications() + runInAction(() => { + this.unreadCount = res.data.count + }) + this.rootStore.emitUnreadNotifications(this.unreadCount) + return this.unreadCount !== old + }) + + /** + * Update read/unread state + */ + async markAllRead() { + try { + this.unreadCount = 0 + this.rootStore.emitUnreadNotifications(0) + for (const notif of this.notifications) { + notif.isRead = true + } + await this.rootStore.agent.updateSeenNotifications() + } catch (e: any) { + this.rootStore.log.warn('Failed to update notifications read state', e) + } + } + + async getNewMostRecent(): Promise { + let old = this.mostRecentNotificationUri + const res = await this.rootStore.agent.listNotifications({ + limit: 1, + }) + if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { + return + } + this.mostRecentNotificationUri = res.data.notifications[0].uri + const notif = new NotificationsFeedItemModel( + this.rootStore, + 'mostRecent', + res.data.notifications[0], + ) + await notif.fetchAdditionalData() + return notif + } + + // 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 notifications', err) + } + } + + // helper functions + // = + + async _replaceAll(res: ListNotifications.Response) { + if (res.data.notifications[0]) { + this.mostRecentNotificationUri = res.data.notifications[0].uri + } + return this._appendAll(res, true) + } + + async _appendAll(res: ListNotifications.Response, replace = false) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + const promises = [] + const itemModels: NotificationsFeedItemModel[] = [] + for (const item of groupNotifications(res.data.notifications)) { + const itemModel = new NotificationsFeedItemModel( + this.rootStore, + `item-${_idCounter++}`, + item, + ) + if (itemModel.needsAdditionalData) { + promises.push(itemModel.fetchAdditionalData()) + } + itemModels.push(itemModel) + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications-view _appendAll()', + e, + ) + }) + runInAction(() => { + if (replace) { + this.notifications = itemModels + } else { + this.notifications = this.notifications.concat(itemModels) + } + }) + } + + async _prependAll(res: ListNotifications.Response) { + const promises = [] + const itemModels: NotificationsFeedItemModel[] = [] + const dedupedNotifs = res.data.notifications.filter( + n1 => + !this.notifications.find( + n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), + ), + ) + for (const item of groupNotifications(dedupedNotifs)) { + const itemModel = new NotificationsFeedItemModel( + this.rootStore, + `item-${_idCounter++}`, + item, + ) + if (itemModel.needsAdditionalData) { + promises.push(itemModel.fetchAdditionalData()) + } + itemModels.push(itemModel) + } + await Promise.all(promises).catch(e => { + this.rootStore.log.error( + 'Uncaught failure during notifications-view _prependAll()', + e, + ) + }) + runInAction(() => { + this.notifications = itemModels.concat(this.notifications) + }) + } + + _updateAll(res: ListNotifications.Response) { + for (const item of res.data.notifications) { + const existingItem = this.notifications.find(item2 => isEq(item, item2)) + if (existingItem) { + existingItem.copy(item, true) + } + } + } +} + +function groupNotifications( + items: ListNotifications.Notification[], +): GroupedNotification[] { + const items2: GroupedNotification[] = [] + for (const item of items) { + const ts = +new Date(item.indexedAt) + let grouped = false + if (GROUPABLE_REASONS.includes(item.reason)) { + for (const item2 of items2) { + const ts2 = +new Date(item2.indexedAt) + if ( + Math.abs(ts2 - ts) < MS_2DAY && + item.reason === item2.reason && + item.reasonSubject === item2.reasonSubject && + item.author.did !== item2.author.did + ) { + item2.additional = item2.additional || [] + item2.additional.push(item) + grouped = true + break + } + } + } + if (!grouped) { + items2.push(item) + } + } + return items2 +} + +type N = ListNotifications.Notification | NotificationsFeedItemModel +function isEq(a: N, b: N) { + // this function has a key subtlety- the indexedAt comparison + // the reason for this is reposts: they set the URI of the original post, not of the repost record + // the indexedAt time will be for the repost however, so we use that to help us + return a.uri === b.uri && a.indexedAt === b.indexedAt +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts new file mode 100644 index 000000000..9e593f313 --- /dev/null +++ b/src/state/models/feeds/posts.ts @@ -0,0 +1,648 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import { + AppBskyFeedGetTimeline as GetTimeline, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedGetAuthorFeed as GetAuthorFeed, + RichText, + jsonToLex, +} from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from 'lib/async/bundle' +import sampleSize from 'lodash.samplesize' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {SUGGESTED_FOLLOWS} from 'lib/constants' +import { + getCombinedCursors, + getMultipleAuthorsPosts, + mergePosts, +} from 'lib/api/build-suggested-posts' +import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' + +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView + +const PAGE_SIZE = 30 +let _idCounter = 0 + +export class PostsFeedItemModel { + // ui state + _reactKey: string = '' + + // data + post: PostView + postRecord?: AppBskyFeedPost.Record + reply?: FeedViewPost['reply'] + reason?: FeedViewPost['reason'] + richText?: RichText + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + v: FeedViewPost, + ) { + this._reactKey = reactKey + this.post = v.post + if (AppBskyFeedPost.isRecord(this.post.record)) { + const valid = AppBskyFeedPost.validateRecord(this.post.record) + if (valid.success) { + this.postRecord = this.post.record + this.richText = new RichText(this.postRecord, {cleanNewlines: true}) + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'Received an invalid app.bsky.feed.post record', + valid.error, + ) + } + } else { + this.postRecord = undefined + this.richText = undefined + rootStore.log.warn( + 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', + this.post.record, + ) + } + this.reply = v.reply + this.reason = v.reason + makeAutoObservable(this, {rootStore: false}) + } + + copy(v: FeedViewPost) { + this.post = v.post + this.reply = v.reply + this.reason = v.reason + } + + copyMetrics(v: FeedViewPost) { + this.post.replyCount = v.post.replyCount + this.post.repostCount = v.post.repostCount + this.post.likeCount = v.post.likeCount + this.post.viewer = v.post.viewer + } + + get reasonRepost(): ReasonRepost | undefined { + if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') { + return this.reason as ReasonRepost + } + } + + 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 PostsFeedSliceModel { + // ui state + _reactKey: string = '' + + // data + items: PostsFeedItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + reactKey: string, + slice: FeedViewPostsSlice, + ) { + this._reactKey = reactKey + for (const item of slice.items) { + this.items.push( + new PostsFeedItemModel(rootStore, `item-${_idCounter++}`, item), + ) + } + makeAutoObservable(this, {rootStore: false}) + } + + get uri() { + if (this.isReply) { + return this.items[1].post.uri + } + return this.items[0].post.uri + } + + get isThread() { + return ( + this.items.length > 1 && + this.items.every( + item => item.post.author.did === this.items[0].post.author.did, + ) + ) + } + + get isReply() { + return this.items.length > 1 && !this.isThread + } + + get rootItem() { + if (this.isReply) { + return this.items[1] + } + return this.items[0] + } + + containsUri(uri: string) { + return !!this.items.find(item => item.post.uri === uri) + } + + isThreadParentAt(i: number) { + if (this.items.length === 1) { + return false + } + return i < this.items.length - 1 + } + + isThreadChildAt(i: number) { + if (this.items.length === 1) { + return false + } + return i > 0 + } +} + +export class PostsFeedModel { + // state + isLoading = false + isRefreshing = false + hasNewLatest = false + hasLoaded = false + error = '' + params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams + hasMore = true + loadMoreCursor: string | undefined + pollCursor: string | undefined + tuner = new FeedTuner() + + // used to linearize async modifications to state + lock = new AwaitLock() + + // data + slices: PostsFeedSliceModel[] = [] + nextSlices: PostsFeedSliceModel[] = [] + + constructor( + public rootStore: RootStoreModel, + public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', + params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + loadMoreCursor: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.slices.length !== 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get nonReplyFeed() { + if (this.feedType === 'author') { + return this.slices.filter(slice => { + const params = this.params as GetAuthorFeed.QueryParams + const item = slice.rootItem + const isRepost = + item?.reasonRepost?.by?.handle === params.actor || + item?.reasonRepost?.by?.did === params.actor + return ( + !item.reply || // not a reply + isRepost || // but allow if it's a repost + (slice.isThread && // or a thread by the user + item.reply?.root.author.did === item.post.author.did) + ) + }) + } else { + return this.slices + } + } + + setHasNewLatest(v: boolean) { + this.hasNewLatest = v + } + + // public api + // = + + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('FeedModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasNewLatest = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.pollCursor = undefined + this.slices = [] + this.nextSlices = [] + this.tuner.reset() + } + + switchFeedType(feedType: 'home' | 'suggested') { + if (this.feedType === feedType) { + return + } + this.feedType = feedType + return this.setup() + } + + get feedTuners() { + if (this.feedType === 'goodstuff') { + return [ + FeedTuner.dedupReposts, + FeedTuner.likedRepliesOnly, + FeedTuner.preferredLangOnly( + this.rootStore.preferences.contentLanguages, + ), + ] + } + if (this.feedType === 'home') { + return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly] + } + return [] + } + + /** + * Load for first render + */ + setup = bundleAsync(async (isRefreshing: boolean = false) => { + this.rootStore.log.debug('FeedModel:setup', {isRefreshing}) + if (isRefreshing) { + this.isRefreshing = true // set optimistically for UI + } + await this.lock.acquireAsync() + try { + this.setHasNewLatest(false) + this.tuner.reset() + this._xLoading(isRefreshing) + try { + const res = await this._getFeed({limit: PAGE_SIZE}) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + } finally { + this.lock.release() + } + }) + + /** + * 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.setup(true) + } + + /** + * Load more posts to the end of the feed + */ + loadMore = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.hasMore || this.hasError) { + return + } + this._xLoading() + try { + const res = await this._getFeed({ + cursor: this.loadMoreCursor, + limit: PAGE_SIZE, + }) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to load more', { + params: this.params, + e, + }) + this.hasMore = false + } + } finally { + this.lock.release() + } + }) + + /** + * Update content in-place + */ + update = bundleAsync(async () => { + await this.lock.acquireAsync() + try { + if (!this.slices.length) { + return + } + this._xLoading() + let numToFetch = this.slices.length + let cursor + try { + do { + const res: GetTimeline.Response = await this._getFeed({ + cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.feed.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.feed.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to update', { + params: this.params, + e, + }) + } + } finally { + this.lock.release() + } + }) + + /** + * Check if new posts are available + */ + async checkForLatest({autoPrepend}: {autoPrepend?: boolean} = {}) { + if (this.hasNewLatest || this.feedType === 'suggested') { + return + } + const res = await this._getFeed({limit: PAGE_SIZE}) + const tuner = new FeedTuner() + const nextSlices = tuner.tune(res.data.feed, this.feedTuners) + if (nextSlices[0]?.uri !== this.slices[0]?.uri) { + const nextSlicesModels = nextSlices.map( + slice => + new PostsFeedSliceModel( + this.rootStore, + `item-${_idCounter++}`, + slice, + ), + ) + if (autoPrepend) { + runInAction(() => { + this.slices = nextSlicesModels.concat( + this.slices.filter(slice1 => + nextSlicesModels.find(slice2 => slice1.uri === slice2.uri), + ), + ) + this.setHasNewLatest(false) + }) + } else { + runInAction(() => { + this.nextSlices = nextSlicesModels + }) + this.setHasNewLatest(true) + } + } else { + this.setHasNewLatest(false) + } + } + + /** + * Sets the current slices to the "next slices" loaded by checkForLatest + */ + resetToLatest() { + if (this.nextSlices.length) { + this.slices = this.nextSlices + } + this.setHasNewLatest(false) + } + + /** + * Removes posts from the feed upon deletion. + */ + onPostDeleted(uri: string) { + let i + do { + i = this.slices.findIndex(slice => slice.containsUri(uri)) + if (i !== -1) { + this.slices.splice(i, 1) + } + } while (i !== -1) + } + + // 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('Posts feed request failed', err) + } + } + + // helper functions + // = + + async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + this.pollCursor = res.data.feed[0]?.post.uri + return this._appendAll(res, true) + } + + async _appendAll( + res: GetTimeline.Response | GetAuthorFeed.Response, + replace = false, + ) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + + const slices = this.tuner.tune(res.data.feed, this.feedTuners) + + const toAppend: PostsFeedSliceModel[] = [] + for (const slice of slices) { + const sliceModel = new PostsFeedSliceModel( + this.rootStore, + `item-${_idCounter++}`, + slice, + ) + toAppend.push(sliceModel) + } + runInAction(() => { + if (replace) { + this.slices = toAppend + } else { + this.slices = this.slices.concat(toAppend) + } + }) + } + + _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + for (const item of res.data.feed) { + const existingSlice = this.slices.find(slice => + slice.containsUri(item.post.uri), + ) + if (existingSlice) { + const existingItem = existingSlice.items.find( + item2 => item2.post.uri === item.post.uri, + ) + if (existingItem) { + existingItem.copyMetrics(item) + } + } + } + } + + protected async _getFeed( + params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, + ): Promise { + params = Object.assign({}, this.params, params) + if (this.feedType === 'suggested') { + const responses = await getMultipleAuthorsPosts( + this.rootStore, + sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20), + params.cursor, + 20, + ) + const combinedCursor = getCombinedCursors(responses) + const finalData = mergePosts(responses, {bestOfOnly: true}) + const lastHeaders = responses[responses.length - 1].headers + return { + success: true, + data: { + feed: finalData, + cursor: combinedCursor, + }, + headers: lastHeaders, + } + } else if (this.feedType === 'home') { + return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) + } else if (this.feedType === 'goodstuff') { + const res = await getGoodStuff( + this.rootStore.session.currentSession?.accessJwt || '', + params as GetTimeline.QueryParams, + ) + res.data.feed = (res.data.feed || []).filter( + item => !item.post.author.viewer?.muted, + ) + return res + } else { + return this.rootStore.agent.getAuthorFeed( + params as GetAuthorFeed.QueryParams, + ) + } + } +} + +// HACK +// temporary off-spec route to get the good stuff +// -prf +async function getGoodStuff( + accessJwt: string, + params: GetTimeline.QueryParams, +): Promise { + const controller = new AbortController() + const to = setTimeout(() => controller.abort(), 15e3) + + const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') + let k: keyof GetTimeline.QueryParams + for (k in params) { + if (typeof params[k] !== 'undefined') { + uri.searchParams.set(k, String(params[k])) + } + } + + const res = await fetch(String(uri), { + method: 'get', + headers: { + accept: 'application/json', + authorization: `Bearer ${accessJwt}`, + }, + signal: controller.signal, + }) + + const resHeaders: Record = {} + res.headers.forEach((value: string, key: string) => { + resHeaders[key] = value + }) + let resBody = await res.json() + + clearTimeout(to) + + return { + success: res.status === 200, + headers: resHeaders, + data: jsonToLex(resBody), + } +} diff --git a/src/state/models/likes-view.ts b/src/state/models/likes-view.ts deleted file mode 100644 index 80e0be0ef..000000000 --- a/src/state/models/likes-view.ts +++ /dev/null @@ -1,131 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '../../third-party/uri' -import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import * as apilib from 'lib/api/index' - -const PAGE_SIZE = 30 - -export type LikeItem = GetLikes.Like - -export class LikesViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - resolvedUri = '' - params: GetLikes.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - uri: string = '' - likes: LikeItem[] = [] - - constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.uri !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - if (!this.resolvedUri) { - await this._resolveUri() - } - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getLikes(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // 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 likes', err) - } - } - - // helper 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() - }) - } - - _replaceAll(res: GetLikes.Response) { - this.likes = [] - this._appendAll(res) - } - - _appendAll(res: GetLikes.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.likes = this.likes.concat(res.data.likes) - } -} diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts new file mode 100644 index 000000000..e88389c56 --- /dev/null +++ b/src/state/models/lists/likes.ts @@ -0,0 +1,131 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AtUri} from '../../../third-party/uri' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' +import * as apilib from 'lib/api/index' + +const PAGE_SIZE = 30 + +export type LikeItem = GetLikes.Like + +export class LikesModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + resolvedUri = '' + params: GetLikes.QueryParams + hasMore = true + loadMoreCursor?: string + + // data + uri: string = '' + likes: LikeItem[] = [] + + constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.uri !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + if (!this.resolvedUri) { + await this._resolveUri() + } + const params = Object.assign({}, this.params, { + uri: this.resolvedUri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.agent.getLikes(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // 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 likes', err) + } + } + + // helper 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() + }) + } + + _replaceAll(res: GetLikes.Response) { + this.likes = [] + this._appendAll(res) + } + + _appendAll(res: GetLikes.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.likes = this.likes.concat(res.data.likes) + } +} diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts new file mode 100644 index 000000000..08cdc9ef5 --- /dev/null +++ b/src/state/models/lists/reposted-by.ts @@ -0,0 +1,135 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AtUri} from '../../../third-party/uri' +import { + AppBskyFeedGetRepostedBy as GetRepostedBy, + AppBskyActorDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import * as apilib from 'lib/api/index' + +const PAGE_SIZE = 30 + +export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic + +export class RepostedByModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + resolvedUri = '' + params: GetRepostedBy.QueryParams + hasMore = true + loadMoreCursor?: string + + // data + uri: string = '' + repostedBy: RepostedByItem[] = [] + + constructor( + public rootStore: RootStoreModel, + params: GetRepostedBy.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.uri !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + this._xLoading(replace) + try { + if (!this.resolvedUri) { + await this._resolveUri() + } + const params = Object.assign({}, this.params, { + uri: this.resolvedUri, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.agent.getRepostedBy(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // 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 reposted by view', err) + } + } + + // helper 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() + }) + } + + _replaceAll(res: GetRepostedBy.Response) { + this.repostedBy = [] + this._appendAll(res) + } + + _appendAll(res: GetRepostedBy.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.repostedBy = this.repostedBy.concat(res.data.repostedBy) + this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) + } +} diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts new file mode 100644 index 000000000..2962d6242 --- /dev/null +++ b/src/state/models/lists/user-followers.ts @@ -0,0 +1,120 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetFollowers as GetFollowers, + AppBskyActorDefs as ActorDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export type FollowerItem = ActorDefs.ProfileViewBasic + +export class UserFollowersModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + params: GetFollowers.QueryParams + hasMore = true + loadMoreCursor?: string + + // data + subject: ActorDefs.ProfileViewBasic = { + did: '', + handle: '', + } + followers: FollowerItem[] = [] + + constructor( + public rootStore: RootStoreModel, + params: GetFollowers.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.subject.did !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.agent.getFollowers(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // 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 user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetFollowers.Response) { + this.followers = [] + this._appendAll(res) + } + + _appendAll(res: GetFollowers.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.followers = this.followers.concat(res.data.followers) + this.rootStore.me.follows.hydrateProfiles(res.data.followers) + } +} diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts new file mode 100644 index 000000000..56432a796 --- /dev/null +++ b/src/state/models/lists/user-follows.ts @@ -0,0 +1,120 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyGraphGetFollows as GetFollows, + AppBskyActorDefs as ActorDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {cleanError} from 'lib/strings/errors' +import {bundleAsync} from 'lib/async/bundle' + +const PAGE_SIZE = 30 + +export type FollowItem = ActorDefs.ProfileViewBasic + +export class UserFollowsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + params: GetFollows.QueryParams + hasMore = true + loadMoreCursor?: string + + // data + subject: ActorDefs.ProfileViewBasic = { + did: '', + handle: '', + } + follows: FollowItem[] = [] + + constructor( + public rootStore: RootStoreModel, + params: GetFollows.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + params: false, + }, + {autoBind: true}, + ) + this.params = params + } + + get hasContent() { + return this.subject.did !== '' + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.agent.getFollows(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // 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 user follows', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetFollows.Response) { + this.follows = [] + this._appendAll(res) + } + + _appendAll(res: GetFollows.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.follows = this.follows.concat(res.data.follows) + this.rootStore.me.follows.hydrateProfiles(res.data.follows) + } +} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 5f670b8f9..26f0849c7 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -1,7 +1,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from './root-store' -import {FeedModel} from './feed-view' -import {NotificationsViewModel} from './notifications-view' +import {PostsFeedModel} from './feeds/posts' +import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' @@ -13,8 +13,8 @@ export class MeModel { avatar: string = '' followsCount: number | undefined followersCount: number | undefined - mainFeed: FeedModel - notifications: NotificationsViewModel + mainFeed: PostsFeedModel + notifications: NotificationsFeedModel follows: MyFollowsCache constructor(public rootStore: RootStoreModel) { @@ -23,10 +23,10 @@ export class MeModel { {rootStore: false, serialize: false, hydrate: false}, {autoBind: true}, ) - this.mainFeed = new FeedModel(this.rootStore, 'home', { + this.mainFeed = new PostsFeedModel(this.rootStore, 'home', { algorithm: 'reverse-chronological', }) - this.notifications = new NotificationsViewModel(this.rootStore, {}) + this.notifications = new NotificationsFeedModel(this.rootStore, {}) this.follows = new MyFollowsCache(this.rootStore) } diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts deleted file mode 100644 index 7089f0125..000000000 --- a/src/state/models/notifications-view.ts +++ /dev/null @@ -1,574 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyNotificationListNotifications as ListNotifications, - AppBskyActorDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from './root-store' -import {PostThreadViewModel} from './post-thread-view' -import {cleanError} from 'lib/strings/errors' - -const GROUPABLE_REASONS = ['like', 'repost', 'follow'] -const PAGE_SIZE = 30 -const MS_1HR = 1e3 * 60 * 60 -const MS_2DAY = MS_1HR * 48 - -let _idCounter = 0 - -export interface GroupedNotification extends ListNotifications.Notification { - additional?: ListNotifications.Notification[] -} - -type SupportedRecord = - | AppBskyFeedPost.Record - | AppBskyFeedRepost.Record - | AppBskyFeedLike.Record - | AppBskyGraphFollow.Record - -export class NotificationsViewItemModel { - // ui state - _reactKey: string = '' - - // data - uri: string = '' - cid: string = '' - author: AppBskyActorDefs.ProfileViewBasic = { - did: '', - handle: '', - avatar: '', - } - reason: string = '' - reasonSubject?: string - record?: SupportedRecord - isRead: boolean = false - indexedAt: string = '' - additional?: NotificationsViewItemModel[] - - // additional data - additionalPost?: PostThreadViewModel - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: GroupedNotification, - ) { - makeAutoObservable(this, {rootStore: false}) - this._reactKey = reactKey - this.copy(v) - } - - copy(v: GroupedNotification, preserve = false) { - this.uri = v.uri - this.cid = v.cid - this.author = v.author - this.reason = v.reason - this.reasonSubject = v.reasonSubject - this.record = this.toSupportedRecord(v.record) - this.isRead = v.isRead - this.indexedAt = v.indexedAt - if (v.additional?.length) { - this.additional = [] - for (const add of v.additional) { - this.additional.push( - new NotificationsViewItemModel(this.rootStore, '', add), - ) - } - } else if (!preserve) { - this.additional = undefined - } - } - - get isLike() { - return this.reason === 'like' - } - - get isRepost() { - return this.reason === 'repost' - } - - get isMention() { - return this.reason === 'mention' - } - - get isReply() { - return this.reason === 'reply' - } - - get isQuote() { - return this.reason === 'quote' - } - - get isFollow() { - return this.reason === 'follow' - } - - get needsAdditionalData() { - if ( - this.isLike || - this.isRepost || - this.isReply || - this.isQuote || - this.isMention - ) { - return !this.additionalPost - } - return false - } - - get subjectUri(): string { - if (this.reasonSubject) { - return this.reasonSubject - } - const record = this.record - if ( - AppBskyFeedRepost.isRecord(record) || - AppBskyFeedLike.isRecord(record) - ) { - return record.subject.uri - } - return '' - } - - toSupportedRecord(v: unknown): SupportedRecord | undefined { - for (const ns of [ - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ]) { - if (ns.isRecord(v)) { - const valid = ns.validateRecord(v) - if (valid.success) { - return v - } else { - this.rootStore.log.warn('Received an invalid record', { - record: v, - error: valid.error, - }) - return - } - } - } - this.rootStore.log.warn( - 'app.bsky.notifications.list served an unsupported record type', - v, - ) - } - - async fetchAdditionalData() { - if (!this.needsAdditionalData) { - return - } - let postUri - if (this.isReply || this.isQuote || this.isMention) { - postUri = this.uri - } else if (this.isLike || this.isRepost) { - postUri = this.subjectUri - } - if (postUri) { - this.additionalPost = new PostThreadViewModel(this.rootStore, { - uri: postUri, - depth: 0, - }) - await this.additionalPost.setup().catch(e => { - this.rootStore.log.error( - 'Failed to load post needed by notification', - e, - ) - }) - } - } -} - -export class NotificationsViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: ListNotifications.QueryParams - hasMore = true - loadMoreCursor?: string - - // used to linearize async modifications to state - lock = new AwaitLock() - - // data - notifications: NotificationsViewItemModel[] = [] - unreadCount = 0 - - // this is used to help trigger push notifications - mostRecentNotificationUri: string | undefined - - constructor( - public rootStore: RootStoreModel, - params: ListNotifications.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - mostRecentNotificationUri: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.notifications.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - this.rootStore.log.debug('NotificationsModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.notifications = [] - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) - this.mostRecentNotificationUri = undefined - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - } - await this.lock.acquireAsync() - try { - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - }) - const res = await this.rootStore.agent.listNotifications(params) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * Reset and load - */ - async refresh() { - return this.setup(true) - } - - /** - * Load more posts to the end of the notifications - */ - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - this.lock.acquireAsync() - try { - this._xLoading() - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - cursor: this.loadMoreCursor, - }) - const res = await this.rootStore.agent.listNotifications(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to load more', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Load more posts at the start of the notifications - */ - loadLatest = bundleAsync(async () => { - if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) { - return this.refresh() - } - this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._prependAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to load latest', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.notifications.length) { - return - } - this._xLoading() - let numToFetch = this.notifications.length - let cursor - try { - do { - const res: ListNotifications.Response = - await this.rootStore.agent.listNotifications({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.notifications.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.notifications.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - - // unread notification apis - // = - - /** - * Get the current number of unread notifications - * returns true if the number changed - */ - loadUnreadCount = bundleAsync(async () => { - const old = this.unreadCount - const res = await this.rootStore.agent.countUnreadNotifications() - runInAction(() => { - this.unreadCount = res.data.count - }) - this.rootStore.emitUnreadNotifications(this.unreadCount) - return this.unreadCount !== old - }) - - /** - * Update read/unread state - */ - async markAllRead() { - try { - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) - for (const notif of this.notifications) { - notif.isRead = true - } - await this.rootStore.agent.updateSeenNotifications() - } catch (e: any) { - this.rootStore.log.warn('Failed to update notifications read state', e) - } - } - - async getNewMostRecent(): Promise { - let old = this.mostRecentNotificationUri - const res = await this.rootStore.agent.listNotifications({ - limit: 1, - }) - if (!res.data.notifications[0] || old === res.data.notifications[0].uri) { - return - } - this.mostRecentNotificationUri = res.data.notifications[0].uri - const notif = new NotificationsViewItemModel( - this.rootStore, - 'mostRecent', - res.data.notifications[0], - ) - await notif.fetchAdditionalData() - return notif - } - - // 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 notifications', err) - } - } - - // helper functions - // = - - async _replaceAll(res: ListNotifications.Response) { - if (res.data.notifications[0]) { - this.mostRecentNotificationUri = res.data.notifications[0].uri - } - return this._appendAll(res, true) - } - - async _appendAll(res: ListNotifications.Response, replace = false) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - const promises = [] - const itemModels: NotificationsViewItemModel[] = [] - for (const item of groupNotifications(res.data.notifications)) { - const itemModel = new NotificationsViewItemModel( - this.rootStore, - `item-${_idCounter++}`, - item, - ) - if (itemModel.needsAdditionalData) { - promises.push(itemModel.fetchAdditionalData()) - } - itemModels.push(itemModel) - } - await Promise.all(promises).catch(e => { - this.rootStore.log.error( - 'Uncaught failure during notifications-view _appendAll()', - e, - ) - }) - runInAction(() => { - if (replace) { - this.notifications = itemModels - } else { - this.notifications = this.notifications.concat(itemModels) - } - }) - } - - async _prependAll(res: ListNotifications.Response) { - const promises = [] - const itemModels: NotificationsViewItemModel[] = [] - const dedupedNotifs = res.data.notifications.filter( - n1 => - !this.notifications.find( - n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)), - ), - ) - for (const item of groupNotifications(dedupedNotifs)) { - const itemModel = new NotificationsViewItemModel( - this.rootStore, - `item-${_idCounter++}`, - item, - ) - if (itemModel.needsAdditionalData) { - promises.push(itemModel.fetchAdditionalData()) - } - itemModels.push(itemModel) - } - await Promise.all(promises).catch(e => { - this.rootStore.log.error( - 'Uncaught failure during notifications-view _prependAll()', - e, - ) - }) - runInAction(() => { - this.notifications = itemModels.concat(this.notifications) - }) - } - - _updateAll(res: ListNotifications.Response) { - for (const item of res.data.notifications) { - const existingItem = this.notifications.find(item2 => isEq(item, item2)) - if (existingItem) { - existingItem.copy(item, true) - } - } - } -} - -function groupNotifications( - items: ListNotifications.Notification[], -): GroupedNotification[] { - const items2: GroupedNotification[] = [] - for (const item of items) { - const ts = +new Date(item.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(item.reason)) { - for (const item2 of items2) { - const ts2 = +new Date(item2.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - item.reason === item2.reason && - item.reasonSubject === item2.reasonSubject && - item.author.did !== item2.author.did - ) { - item2.additional = item2.additional || [] - item2.additional.push(item) - grouped = true - break - } - } - } - if (!grouped) { - items2.push(item) - } - } - return items2 -} - -type N = ListNotifications.Notification | NotificationsViewItemModel -function isEq(a: N, b: N) { - // this function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - return a.uri === b.uri && a.indexedAt === b.indexedAt -} diff --git a/src/state/models/post-thread-view.ts b/src/state/models/post-thread-view.ts deleted file mode 100644 index c5395b9c8..000000000 --- a/src/state/models/post-thread-view.ts +++ /dev/null @@ -1,372 +0,0 @@ -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 { - let counter = 0 - while (true) { - yield `item-${counter++}` - } -} - -export class PostThreadViewPostModel { - // ui state - _reactKey: string = '' - _depth = 0 - _isHighlightedPost = false - _showParentReplyLine = false - _showChildReplyLine = false - _hasMore = false - - // data - post: AppBskyFeedDefs.PostView - postRecord?: FeedPost.Record - parent?: PostThreadViewPostModel | AppBskyFeedDefs.NotFoundPost - replies?: (PostThreadViewPostModel | 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, - v: AppBskyFeedDefs.ThreadViewPost, - higlightedPostUri: string, - includeParent = true, - includeChildren = true, - ) { - // parents - if (includeParent && v.parent) { - if (AppBskyFeedDefs.isThreadViewPost(v.parent)) { - const parentModel = new PostThreadViewPostModel( - 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 PostThreadViewPostModel( - 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 PostThreadViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - notFound = false - resolvedUri = '' - params: GetPostThread.QueryParams - - // data - thread?: PostThreadViewPostModel - - 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 PostThreadViewPostModel( - 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/post.ts b/src/state/models/post.ts deleted file mode 100644 index c7f2896ba..000000000 --- a/src/state/models/post.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 = { - [P in keyof T as string extends P - ? never - : number extends P - ? never - : P]: T[P] -} -export class PostModel implements RemoveIndex { - // 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/profile-view.ts b/src/state/models/profile-view.ts deleted file mode 100644 index eacc6a298..000000000 --- a/src/state/models/profile-view.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 ProfileViewViewerModel { - muted?: boolean - following?: string - followedBy?: string - - constructor() { - makeAutoObservable(this) - } -} - -export class ProfileViewModel { - // 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 ProfileViewViewerModel() - - // 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) - } -} diff --git a/src/state/models/profiles-view.ts b/src/state/models/profiles-view.ts deleted file mode 100644 index 30e6d0442..000000000 --- a/src/state/models/profiles-view.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {LRUMap} from 'lru_map' -import {RootStoreModel} from './root-store' -import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' - -type CacheValue = Promise | GetProfile.Response -export class ProfilesViewModel { - cache: LRUMap = new LRUMap(100) - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - cache: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async getProfile(did: string) { - const cached = this.cache.get(did) - if (cached) { - try { - return await cached - } catch (e) { - // ignore, we'll try again - } - } - try { - const promise = this.rootStore.agent.getProfile({ - actor: did, - }) - this.cache.set(did, promise) - const res = await promise - this.cache.set(did, res) - return res - } catch (e) { - this.cache.delete(did) - throw e - } - } - - overwrite(did: string, res: GetProfile.Response) { - if (this.cache.has(did)) { - this.cache.set(did, res) - } - } -} diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts deleted file mode 100644 index c9b089c70..000000000 --- a/src/state/models/reposted-by-view.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '../../third-party/uri' -import { - AppBskyFeedGetRepostedBy as GetRepostedBy, - AppBskyActorDefs, -} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import * as apilib from 'lib/api/index' - -const PAGE_SIZE = 30 - -export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic - -export class RepostedByViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - resolvedUri = '' - params: GetRepostedBy.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - uri: string = '' - repostedBy: RepostedByItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetRepostedBy.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.uri !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - this._xLoading(replace) - try { - if (!this.resolvedUri) { - await this._resolveUri() - } - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getRepostedBy(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // 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 reposted by view', err) - } - } - - // helper 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() - }) - } - - _replaceAll(res: GetRepostedBy.Response) { - this.repostedBy = [] - this._appendAll(res) - } - - _appendAll(res: GetRepostedBy.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.repostedBy = this.repostedBy.concat(res.data.repostedBy) - this.rootStore.me.follows.hydrateProfiles(res.data.repostedBy) - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 0c2a31d28..d4fcbf74e 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -12,9 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' import {ShellUiModel} from './ui/shell' -import {ProfilesViewModel} from './profiles-view' +import {ProfilesCache} from './cache/profiles-view' import {LinkMetasCache} from './cache/link-metas' -import {NotificationsViewItemModel} from './notifications-view' +import {NotificationsFeedItemModel} from './feeds/notifications' import {MeModel} from './me' import {PreferencesModel} from './ui/preferences' import {resetToTab} from '../../Navigation' @@ -36,7 +36,7 @@ export class RootStoreModel { shell = new ShellUiModel(this) preferences = new PreferencesModel() me = new MeModel(this) - profiles = new ProfilesViewModel(this) + profiles = new ProfilesCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() @@ -205,11 +205,11 @@ export class RootStoreModel { // a notification has been queued for push onPushNotification( - handler: (notif: NotificationsViewItemModel) => void, + handler: (notif: NotificationsFeedItemModel) => void, ): EmitterSubscription { return DeviceEventEmitter.addListener('push-notification', handler) } - emitPushNotification(notif: NotificationsViewItemModel) { + emitPushNotification(notif: NotificationsFeedItemModel) { DeviceEventEmitter.emit('push-notification', notif) } diff --git a/src/state/models/suggested-posts-view.ts b/src/state/models/suggested-posts-view.ts deleted file mode 100644 index 46bf235ff..000000000 --- a/src/state/models/suggested-posts-view.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from './root-store' -import {FeedItemModel} from './feed-view' -import {cleanError} from 'lib/strings/errors' -import {TEAM_HANDLES} from 'lib/constants' -import { - getMultipleAuthorsPosts, - mergePosts, -} from 'lib/api/build-suggested-posts' - -export class SuggestedPostsView { - // state - isLoading = false - hasLoaded = false - error = '' - - // data - posts: FeedItemModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.posts.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async setup() { - this._xLoading() - try { - const responses = await getMultipleAuthorsPosts( - this.rootStore, - TEAM_HANDLES(String(this.rootStore.agent.service)), - undefined, - 30, - ) - runInAction(() => { - const finalPosts = mergePosts(responses, {repostsOnly: true}) - // hydrate into models - this.posts = finalPosts.map((post, i) => { - // strip the reasons to hide that these are reposts - delete post.reason - return new FeedItemModel(this.rootStore, `post-${i}`, post) - }) - }) - this._xIdle() - } catch (e: any) { - this.rootStore.log.error('SuggestedPostsView: Failed to load posts', { - e, - }) - this._xIdle() // dont bubble to the user - } - } - - // 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 suggested posts', err) - } - } -} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 59529aa39..d06a196f3 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -1,7 +1,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' -import {ProfileViewModel} from '../profile-view' -import {FeedModel} from '../feed-view' +import {ProfileModel} from '../content/profile' +import {PostsFeedModel} from '../feeds/posts' export enum Sections { Posts = 'Posts', @@ -20,8 +20,8 @@ export class ProfileUiModel { static EMPTY_ITEM = {_reactKey: '__empty__'} // data - profile: ProfileViewModel - feed: FeedModel + profile: ProfileModel + feed: PostsFeedModel // ui state selectedViewIndex = 0 @@ -38,14 +38,14 @@ export class ProfileUiModel { }, {autoBind: true}, ) - this.profile = new ProfileViewModel(rootStore, {actor: params.user}) - this.feed = new FeedModel(rootStore, 'author', { + this.profile = new ProfileModel(rootStore, {actor: params.user}) + this.feed = new PostsFeedModel(rootStore, 'author', { actor: params.user, limit: 10, }) } - get currentView(): FeedModel { + get currentView(): PostsFeedModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies @@ -137,7 +137,7 @@ export class ProfileUiModel { async update() { const view = this.currentView - if (view instanceof FeedModel) { + if (view instanceof PostsFeedModel) { await view.update() } } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 7f57d5b54..b782dd2f7 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,7 +1,7 @@ import {AppBskyEmbedRecord} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable} from 'mobx' -import {ProfileViewModel} from '../profile-view' +import {ProfileModel} from '../content/profile' import {isObj, hasProp} from 'lib/type-guards' import {PickedMedia} from 'lib/media/types' @@ -14,7 +14,7 @@ export interface ConfirmModal { export interface EditProfileModal { name: 'edit-profile' - profileView: ProfileViewModel + profileView: ProfileModel onUpdate?: () => void } @@ -77,7 +77,7 @@ interface LightboxModel {} export class ProfileImageLightbox implements LightboxModel { name = 'profile-image' - constructor(public profileView: ProfileViewModel) { + constructor(public profileView: ProfileModel) { makeAutoObservable(this) } } diff --git a/src/state/models/user-autocomplete-view.ts b/src/state/models/user-autocomplete-view.ts deleted file mode 100644 index ad89bb08b..000000000 --- a/src/state/models/user-autocomplete-view.ts +++ /dev/null @@ -1,103 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' -import AwaitLock from 'await-lock' -import {RootStoreModel} from './root-store' - -export class UserAutocompleteViewModel { - // state - isLoading = false - isActive = false - prefix = '' - lock = new AwaitLock() - - // data - follows: AppBskyActorDefs.ProfileViewBasic[] = [] - searchRes: AppBskyActorDefs.ProfileViewBasic[] = [] - knownHandles: Set = new Set() - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - knownHandles: false, - }, - {autoBind: true}, - ) - } - - get suggestions() { - if (!this.isActive) { - return [] - } - if (this.prefix) { - return this.searchRes.map(user => ({ - handle: user.handle, - displayName: user.displayName, - avatar: user.avatar, - })) - } - return this.follows.map(follow => ({ - handle: follow.handle, - displayName: follow.displayName, - avatar: follow.avatar, - })) - } - - // public api - // = - - async setup() { - await this._getFollows() - } - - setActive(v: boolean) { - this.isActive = v - } - - async setPrefix(prefix: string) { - const origPrefix = prefix.trim() - this.prefix = origPrefix - await this.lock.acquireAsync() - try { - if (this.prefix) { - if (this.prefix !== origPrefix) { - return // another prefix was set before we got our chance - } - await this._search() - } else { - this.searchRes = [] - } - } finally { - this.lock.release() - } - } - - // internal - // = - - async _getFollows() { - const res = await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did || '', - }) - runInAction(() => { - this.follows = res.data.follows - for (const f of this.follows) { - this.knownHandles.add(f.handle) - } - }) - } - - async _search() { - const res = await this.rootStore.agent.searchActorsTypeahead({ - term: this.prefix, - limit: 8, - }) - runInAction(() => { - this.searchRes = res.data.actors - for (const u of this.searchRes) { - this.knownHandles.add(u.handle) - } - }) - } -} diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts deleted file mode 100644 index 055032eb7..000000000 --- a/src/state/models/user-followers-view.ts +++ /dev/null @@ -1,120 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetFollowers as GetFollowers, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' - -const PAGE_SIZE = 30 - -export type FollowerItem = ActorDefs.ProfileViewBasic - -export class UserFollowersViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetFollowers.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - subject: ActorDefs.ProfileViewBasic = { - did: '', - handle: '', - } - followers: FollowerItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetFollowers.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.subject.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getFollowers(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // 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 user followers', err) - } - } - - // helper functions - // = - - _replaceAll(res: GetFollowers.Response) { - this.followers = [] - this._appendAll(res) - } - - _appendAll(res: GetFollowers.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.followers = this.followers.concat(res.data.followers) - this.rootStore.me.follows.hydrateProfiles(res.data.followers) - } -} diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts deleted file mode 100644 index 6d9d84592..000000000 --- a/src/state/models/user-follows-view.ts +++ /dev/null @@ -1,120 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetFollows as GetFollows, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' - -const PAGE_SIZE = 30 - -export type FollowItem = ActorDefs.ProfileViewBasic - -export class UserFollowsViewModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetFollows.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - subject: ActorDefs.ProfileViewBasic = { - did: '', - handle: '', - } - follows: FollowItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetFollows.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.subject.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getFollows(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // 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 user follows', err) - } - } - - // helper functions - // = - - _replaceAll(res: GetFollows.Response) { - this.follows = [] - this._appendAll(res) - } - - _appendAll(res: GetFollows.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.follows = this.follows.concat(res.data.follows) - this.rootStore.me.follows.hydrateProfiles(res.data.follows) - } -} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 6009debdd..10a44542e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -15,7 +15,7 @@ import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' @@ -69,8 +69,8 @@ export const ComposePost = observer(function ComposePost({ ) const [selectedPhotos, setSelectedPhotos] = React.useState([]) - const autocompleteView = React.useMemo( - () => new UserAutocompleteViewModel(store), + const autocompleteView = React.useMemo( + () => new UserAutocompleteModel(store), [store], ) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 393d168fe..ec2186572 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -11,7 +11,7 @@ import PasteInput, { } from '@mattermost/react-native-paste-input' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' @@ -36,7 +36,7 @@ interface TextInputProps { richtext: RichText placeholder: string suggestedLinks: Set - autocompleteView: UserAutocompleteViewModel + autocompleteView: UserAutocompleteModel setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set) => void diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index ad891fa5b..68d0d10ce 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -9,7 +9,7 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {createSuggestion} from './web/Autocomplete' export interface TextInputRef { @@ -21,7 +21,7 @@ interface TextInputProps { richtext: RichText placeholder: string suggestedLinks: Set - autocompleteView: UserAutocompleteViewModel + autocompleteView: UserAutocompleteModel setRichText: (v: RichText) => void onPhotoPasted: (uri: string) => void onSuggestedLinksChanged: (uris: Set) => void diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index 293c89da5..879bac071 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,7 +1,7 @@ import React, {useEffect} from 'react' import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' @@ -11,7 +11,7 @@ export const Autocomplete = observer( view, onSelect, }: { - view: UserAutocompleteViewModel + view: UserAutocompleteModel onSelect: (item: string) => void }) => { const pal = usePalette('default') diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index fbe438969..7c6f8770b 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -11,7 +11,7 @@ import { SuggestionProps, SuggestionKeyDownProps, } from '@tiptap/suggestion' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' interface MentionListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean @@ -20,7 +20,7 @@ interface MentionListRef { export function createSuggestion({ autocompleteView, }: { - autocompleteView: UserAutocompleteViewModel + autocompleteView: UserAutocompleteModel }): Omit { return { async items({query}) { diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx index 9c7745dfa..6d2f39636 100644 --- a/src/view/com/discover/SuggestedPosts.tsx +++ b/src/view/com/discover/SuggestedPosts.tsx @@ -2,7 +2,7 @@ import React from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' -import {SuggestedPostsView} from 'state/models/suggested-posts-view' +import {SuggestedPostsModel} from 'state/models/discovery/suggested-posts' import {s} from 'lib/styles' import {FeedItem as Post} from '../posts/FeedItem' import {Text} from '../util/text/Text' @@ -11,8 +11,8 @@ import {usePalette} from 'lib/hooks/usePalette' export const SuggestedPosts = observer(() => { const pal = usePalette('default') const store = useStores() - const suggestedPostsView = React.useMemo( - () => new SuggestedPostsView(store), + const suggestedPostsView = React.useMemo( + () => new SuggestedPostsModel(store), [store], ) diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index 0b81d7f39..4fbf7070b 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -12,7 +12,7 @@ import {PickedMedia} from '../../../lib/media/picker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from 'state/index' -import {ProfileViewModel} from 'state/models/profile-view' +import {ProfileModel} from 'state/models/content/profile' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' @@ -30,7 +30,7 @@ export function Component({ profileView, onUpdate, }: { - profileView: ProfileViewModel + profileView: ProfileModel onUpdate?: () => void }) { const store = useStores() diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index b2fba0fc9..83fa0a990 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -2,7 +2,7 @@ import React, {MutableRefObject} from 'react' import {observer} from 'mobx-react-lite' import {CenteredView, FlatList} from '../util/Views' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import {NotificationsViewModel} from 'state/models/notifications-view' +import {NotificationsFeedModel} from 'state/models/feeds/notifications' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' @@ -19,7 +19,7 @@ export const Feed = observer(function Feed({ onPressTryAgain, onScroll, }: { - view: NotificationsViewModel + view: NotificationsFeedModel scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollCb diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 7d584e8e6..6bff48f08 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -14,8 +14,8 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsViewItemModel} from 'state/models/notifications-view' -import {PostThreadViewModel} from 'state/models/post-thread-view' +import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' +import {PostThreadModel} from 'state/models/content/post-thread' import {s, colors} from 'lib/styles' import {ago} from 'lib/strings/time' import {pluralize} from 'lib/strings/helpers' @@ -42,7 +42,7 @@ interface Author { export const FeedItem = observer(function FeedItem({ item, }: { - item: NotificationsViewItemModel + item: NotificationsFeedItemModel }) { const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = React.useState(false) @@ -338,7 +338,7 @@ function ExpandedAuthorsList({ function AdditionalPostText({ additionalPost, }: { - additionalPost?: PostThreadViewModel + additionalPost?: PostThreadModel }) { const pal = usePalette('default') if ( diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 3ca147b8d..1b65c04fc 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -2,7 +2,7 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {LikesViewModel, LikeItem} from 'state/models/likes-view' +import {LikesModel, LikeItem} from 'state/models/lists/likes' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {useStores} from 'state/index' @@ -11,10 +11,7 @@ import {usePalette} from 'lib/hooks/usePalette' export const PostLikedBy = observer(function ({uri}: {uri: string}) { const pal = usePalette('default') const store = useStores() - const view = React.useMemo( - () => new LikesViewModel(store, {uri}), - [store, uri], - ) + const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri]) useEffect(() => { view.loadMore().catch(err => store.log.error('Failed to fetch likes', err)) diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 147d0271f..30f8fd445 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -2,10 +2,7 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import { - RepostedByViewModel, - RepostedByItem, -} from 'state/models/reposted-by-view' +import {RepostedByModel, RepostedByItem} from 'state/models/lists/reposted-by' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {useStores} from 'state/index' @@ -19,7 +16,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new RepostedByViewModel(store, {uri}), + () => new RepostedByModel(store, {uri}), [store, uri], ) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 569c6e392..40a6f48c8 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -9,9 +9,9 @@ import { } from 'react-native' import {CenteredView, FlatList} from '../util/Views' import { - PostThreadViewModel, - PostThreadViewPostModel, -} from 'state/models/post-thread-view' + PostThreadModel, + PostThreadItemModel, +} from 'state/models/content/post-thread' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -31,7 +31,7 @@ const BOTTOM_BORDER = { _reactKey: '__bottom_border__', _isHighlightedPost: false, } -type YieldedItem = PostThreadViewPostModel | typeof REPLY_PROMPT +type YieldedItem = PostThreadItemModel | typeof REPLY_PROMPT export const PostThread = observer(function PostThread({ uri, @@ -39,7 +39,7 @@ export const PostThread = observer(function PostThread({ onPressReply, }: { uri: string - view: PostThreadViewModel + view: PostThreadModel onPressReply: () => void }) { const pal = usePalette('default') @@ -109,7 +109,7 @@ export const PostThread = observer(function PostThread({ // I could find to get a border positioned directly under the last item // -prf return - } else if (item instanceof PostThreadViewPostModel) { + } else if (item instanceof PostThreadItemModel) { return } return <> @@ -187,14 +187,14 @@ export const PostThread = observer(function PostThread({ }) function* flattenThread( - post: PostThreadViewPostModel, + post: PostThreadItemModel, isAscending = false, ): Generator { if (post.parent) { if ('notFound' in post.parent && post.parent.notFound) { // TODO render not found } else { - yield* flattenThread(post.parent as PostThreadViewPostModel, true) + yield* flattenThread(post.parent as PostThreadItemModel, true) } } yield post @@ -206,7 +206,7 @@ function* flattenThread( if ('notFound' in reply && reply.notFound) { // TODO render not found } else { - yield* flattenThread(reply as PostThreadViewPostModel) + yield* flattenThread(reply as PostThreadItemModel) } } } else if (!isAscending && !post.parent && post.post.replyCount) { diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index cf2148060..5a983698c 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -7,7 +7,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostThreadViewPostModel} from 'state/models/post-thread-view' +import {PostThreadItemModel} from 'state/models/content/post-thread' import {Link} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' @@ -31,7 +31,7 @@ export const PostThreadItem = observer(function PostThreadItem({ item, onPostReply, }: { - item: PostThreadViewPostModel + item: PostThreadItemModel onPostReply: () => void }) { const pal = usePalette('default') diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 6b3dc3ac6..3312762de 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -11,7 +11,7 @@ import {observer} from 'mobx-react-lite' import Clipboard from '@react-native-clipboard/clipboard' import {AtUri} from '../../../third-party/uri' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadViewModel} from 'state/models/post-thread-view' +import {PostThreadModel} from 'state/models/content/post-thread' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -34,21 +34,21 @@ export const Post = observer(function Post({ style, }: { uri: string - initView?: PostThreadViewModel + initView?: PostThreadModel showReplyLine?: boolean hideError?: boolean style?: StyleProp }) { const pal = usePalette('default') const store = useStores() - const [view, setView] = useState(initView) + const [view, setView] = useState(initView) const [deleted, setDeleted] = useState(false) useEffect(() => { if (initView || view?.params.uri === uri) { return // no change needed? or trigger refresh? } - const newView = new PostThreadViewModel(store, {uri, depth: 0}) + const newView = new PostThreadModel(store, {uri, depth: 0}) setView(newView) newView.setup().catch(err => store.log.error('Failed to fetch post', err)) }, [initView, uri, view?.params.uri, store]) diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx index a460b57c4..1a56a5dbf 100644 --- a/src/view/com/post/PostText.tsx +++ b/src/view/com/post/PostText.tsx @@ -4,7 +4,7 @@ import {StyleProp, StyleSheet, TextStyle, View} from 'react-native' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' -import {PostModel} from 'state/models/post' +import {PostModel} from 'state/models/content/post' import {useStores} from 'state/index' export const PostText = observer(function PostText({ diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index d07afca34..ddebe5e09 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -11,7 +11,7 @@ import { import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' -import {FeedModel} from 'state/models/feed-view' +import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' @@ -33,7 +33,7 @@ export const Feed = observer(function Feed({ testID, headerOffset = 0, }: { - feed: FeedModel + feed: PostsFeedModel style?: StyleProp showPostFollowBtn?: boolean scrollElRef?: MutableRefObject | null> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 734034a89..8a019a2ef 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -7,7 +7,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {FeedItemModel} from 'state/models/feed-view' +import {PostsFeedItemModel} from 'state/models/feeds/posts' import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -30,7 +30,7 @@ export const FeedItem = observer(function ({ showFollowBtn, ignoreMuteFor, }: { - item: FeedItemModel + item: PostsFeedItemModel isThreadChild?: boolean isThreadParent?: boolean showReplyLine?: boolean diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 806ced20c..7fcd1cd2d 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {FeedSliceModel} from 'state/models/feed-view' +import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {AtUri} from '../../../third-party/uri' import {Link} from '../util/Link' import {Text} from '../util/text/Text' @@ -13,7 +13,7 @@ export function FeedSlice({ showFollowBtn, ignoreMuteFor, }: { - slice: FeedSliceModel + slice: PostsFeedSliceModel showFollowBtn?: boolean ignoreMuteFor?: string }) { @@ -66,7 +66,7 @@ export function FeedSlice({ ) } -function ViewFullThread({slice}: {slice: FeedSliceModel}) { +function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { const pal = usePalette('default') const itemHref = React.useMemo(() => { const urip = new AtUri(slice.rootItem.post.uri) diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 8d489ad0a..0ef652a98 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -2,9 +2,9 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import { - UserFollowersViewModel, + UserFollowersModel, FollowerItem, -} from 'state/models/user-followers-view' +} from 'state/models/lists/user-followers' import {CenteredView, FlatList} from '../util/Views' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' @@ -19,7 +19,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowersViewModel(store, {actor: name}), + () => new UserFollowersModel(store, {actor: name}), [store, name], ) diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index 849b33441..54b5a319a 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -2,7 +2,7 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' import {CenteredView, FlatList} from '../util/Views' -import {UserFollowsViewModel, FollowItem} from 'state/models/user-follows-view' +import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' import {useStores} from 'state/index' @@ -16,7 +16,7 @@ export const ProfileFollows = observer(function ProfileFollows({ const pal = usePalette('default') const store = useStores() const view = React.useMemo( - () => new UserFollowsViewModel(store, {actor: name}), + () => new UserFollowsModel(store, {actor: name}), [store, name], ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 6294c627b..878d837c9 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -13,7 +13,7 @@ import { } from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {BlurView} from '../util/BlurView' -import {ProfileViewModel} from 'state/models/profile-view' +import {ProfileModel} from 'state/models/content/profile' import {useStores} from 'state/index' import {ProfileImageLightbox} from 'state/models/ui/shell' import {pluralize} from 'lib/strings/helpers' @@ -34,13 +34,7 @@ import {isDesktopWeb} from 'platform/detection' const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30} export const ProfileHeader = observer( - ({ - view, - onRefreshAll, - }: { - view: ProfileViewModel - onRefreshAll: () => void - }) => { + ({view, onRefreshAll}: {view: ProfileModel; onRefreshAll: () => void}) => { const pal = usePalette('default') // loading @@ -91,7 +85,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({ view, onRefreshAll, }: { - view: ProfileViewModel + view: ProfileModel onRefreshAll: () => void }) { const pal = usePalette('default') diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 871aae9c7..1f9abdafa 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -4,7 +4,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' -import {FeedModel} from 'state/models/feed-view' +import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' @@ -26,7 +26,7 @@ export const HomeScreen = withAuthRequired((_opts: Props) => { const [selectedPage, setSelectedPage] = React.useState(0) const algoFeed = React.useMemo(() => { - const feed = new FeedModel(store, 'goodstuff', {}) + const feed = new PostsFeedModel(store, 'goodstuff', {}) feed.setup() return feed }, [store]) @@ -104,7 +104,7 @@ const FeedPage = observer( renderEmptyState, }: { testID?: string - feed: FeedModel + feed: PostsFeedModel isPageFocused: boolean renderEmptyState?: () => JSX.Element }) => { diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 9bfdcc95a..e3ceb0bef 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -7,7 +7,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {ComposePrompt} from 'view/com/composer/Prompt' -import {PostThreadViewModel} from 'state/models/post-thread-view' +import {PostThreadModel} from 'state/models/content/post-thread' import {useStores} from 'state/index' import {s} from 'lib/styles' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -22,8 +22,8 @@ export const PostThreadScreen = withAuthRequired(({route}: Props) => { const safeAreaInsets = useSafeAreaInsets() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const view = useMemo( - () => new PostThreadViewModel(store, {uri}), + const view = useMemo( + () => new PostThreadModel(store, {uri}), [store, uri], ) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 556578e77..e3158a973 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -8,7 +8,7 @@ import {ViewSelector} from '../com/util/ViewSelector' import {CenteredView} from '../com/util/Views' import {ProfileUiModel} from 'state/models/ui/profile' import {useStores} from 'state/index' -import {FeedSliceModel} from 'state/models/feed-view' +import {PostsFeedSliceModel} from 'state/models/feeds/posts' import {ProfileHeader} from '../com/profile/ProfileHeader' import {FeedSlice} from '../com/posts/FeedSlice' import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder' @@ -124,7 +124,7 @@ export const ProfileScreen = withAuthRequired( style={styles.emptyState} /> ) - } else if (item instanceof FeedSliceModel) { + } else if (item instanceof PostsFeedSliceModel) { return } return diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx index e6947013e..e1fb3ec0a 100644 --- a/src/view/screens/Search.tsx +++ b/src/view/screens/Search.tsx @@ -16,7 +16,7 @@ import { import {observer} from 'mobx-react-lite' import {Text} from 'view/com/util/text/Text' import {useStores} from 'state/index' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {SearchUIModel} from 'state/models/ui/search' import {FoafsModel} from 'state/models/discovery/foafs' import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' @@ -37,8 +37,8 @@ export const SearchScreen = withAuthRequired( const onMainScroll = useOnMainScroll(store) const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') - const autocompleteView = React.useMemo( - () => new UserAutocompleteViewModel(store), + const autocompleteView = React.useMemo( + () => new UserAutocompleteModel(store), [store], ) const foafs = React.useMemo( diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index 101840b89..1bc12add1 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,7 +1,7 @@ import React from 'react' import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' -import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -16,8 +16,8 @@ export const DesktopSearch = observer(function DesktopSearch() { const textInput = React.useRef(null) const [isInputFocused, setIsInputFocused] = React.useState(false) const [query, setQuery] = React.useState('') - const autocompleteView = React.useMemo( - () => new UserAutocompleteViewModel(store), + const autocompleteView = React.useMemo( + () => new UserAutocompleteModel(store), [store], ) const navigation = useNavigation() -- cgit 1.4.1