diff options
Diffstat (limited to 'src/state/models')
-rw-r--r-- | src/state/models/cache/image-sizes.ts | 1 | ||||
-rw-r--r-- | src/state/models/content/post-thread.ts | 13 | ||||
-rw-r--r-- | src/state/models/discovery/feeds.ts | 97 | ||||
-rw-r--r-- | src/state/models/feeds/custom-feed.ts | 120 | ||||
-rw-r--r-- | src/state/models/feeds/multi-feed.ts | 216 | ||||
-rw-r--r-- | src/state/models/feeds/notifications.ts | 10 | ||||
-rw-r--r-- | src/state/models/feeds/post.ts | 265 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 363 | ||||
-rw-r--r-- | src/state/models/lists/actor-feeds.ts | 120 | ||||
-rw-r--r-- | src/state/models/log.ts | 16 | ||||
-rw-r--r-- | src/state/models/me.ts | 6 | ||||
-rw-r--r-- | src/state/models/media/gallery.ts | 24 | ||||
-rw-r--r-- | src/state/models/media/image.ts | 179 | ||||
-rw-r--r-- | src/state/models/session.ts | 4 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 157 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 36 | ||||
-rw-r--r-- | src/state/models/ui/saved-feeds.ts | 185 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 2 |
18 files changed, 1379 insertions, 435 deletions
diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts index bbfb9612b..c30a68f4d 100644 --- a/src/state/models/cache/image-sizes.ts +++ b/src/state/models/cache/image-sizes.ts @@ -16,6 +16,7 @@ export class ImageSizesCache { if (Dimensions) { return Dimensions } + const prom = this.activeRequests.get(uri) || new Promise<Dimensions>(resolve => { diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 74a75d803..577b76e01 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -118,7 +118,7 @@ export class PostThreadItemModel { assignTreeModels( v: AppBskyFeedDefs.ThreadViewPost, - higlightedPostUri: string, + highlightedPostUri: string, includeParent = true, includeChildren = true, ) { @@ -130,7 +130,12 @@ export class PostThreadItemModel { parentModel._showChildReplyLine = true if (v.parent.parent) { parentModel._showParentReplyLine = true - parentModel.assignTreeModels(v.parent, higlightedPostUri, true, false) + parentModel.assignTreeModels( + v.parent, + highlightedPostUri, + true, + false, + ) } this.parent = parentModel } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { @@ -147,10 +152,10 @@ export class PostThreadItemModel { const itemModel = new PostThreadItemModel(this.rootStore, item) itemModel._depth = this._depth + 1 itemModel._showParentReplyLine = - itemModel.parentUri !== higlightedPostUri && replies.length === 0 + itemModel.parentUri !== highlightedPostUri && replies.length === 0 if (item.replies?.length) { itemModel._showChildReplyLine = true - itemModel.assignTreeModels(item, higlightedPostUri, false, true) + itemModel.assignTreeModels(item, highlightedPostUri, false, true) } replies.push(itemModel) } else if (AppBskyFeedDefs.isNotFoundPost(item)) { diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts new file mode 100644 index 000000000..26a8d650c --- /dev/null +++ b/src/state/models/discovery/feeds.ts @@ -0,0 +1,97 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +export class FeedsDiscoveryModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + + // data + feeds: CustomFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasMore() { + return false + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + refresh = bundleAsync(async () => { + this._xLoading() + try { + const res = + await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators( + {}, + ) + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.feeds = [] + } + + // state transitions + // = + + _xLoading() { + this.isLoading = true + this.isRefreshing = true + 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 popular feeds', err) + } + } + + // helper functions + // = + + _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { + this.feeds = [] + for (const f of res.data.feeds) { + this.feeds.push(new CustomFeedModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts new file mode 100644 index 000000000..8fc1eb1ec --- /dev/null +++ b/src/state/models/feeds/custom-feed.ts @@ -0,0 +1,120 @@ +import {AppBskyFeedDefs} from '@atproto/api' +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {updateDataOptimistically} from 'lib/async/revertible' + +export class CustomFeedModel { + // data + _reactKey: string + data: AppBskyFeedDefs.GeneratorView + isOnline: boolean + isValid: boolean + + constructor( + public rootStore: RootStoreModel, + view: AppBskyFeedDefs.GeneratorView, + isOnline?: boolean, + isValid?: boolean, + ) { + this._reactKey = view.uri + this.data = view + this.isOnline = isOnline ?? true + this.isValid = isValid ?? true + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + // local actions + // = + + get uri() { + return this.data.uri + } + + get displayName() { + if (this.data.displayName) { + return sanitizeDisplayName(this.data.displayName) + } + return `Feed by @${this.data.creator.handle}` + } + + get isSaved() { + return this.rootStore.preferences.savedFeeds.includes(this.uri) + } + + get isLiked() { + return this.data.viewer?.like + } + + // public apis + // = + + async save() { + await this.rootStore.preferences.addSavedFeed(this.uri) + } + + async unsave() { + await this.rootStore.preferences.removeSavedFeed(this.uri) + } + + async like() { + try { + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = 'pending' + this.data.likeCount = (this.data.likeCount || 0) + 1 + }, + () => this.rootStore.agent.like(this.data.uri, this.data.cid), + res => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = res.uri + }, + ) + } catch (e: any) { + this.rootStore.log.error('Failed to like feed', e) + } + } + + async unlike() { + if (!this.data.viewer?.like) { + return + } + try { + const likeUri = this.data.viewer.like + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = undefined + this.data.likeCount = (this.data.likeCount || 1) - 1 + }, + () => this.rootStore.agent.deleteLike(likeUri), + ) + } catch (e: any) { + this.rootStore.log.error('Failed to unlike feed', e) + } + } + + async reload() { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.data.uri, + }) + runInAction(() => { + this.data = res.data.view + this.isOnline = res.data.isOnline + this.isValid = res.data.isValid + }) + } + + serialize() { + return JSON.stringify(this.data) + } +} diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts new file mode 100644 index 000000000..3c13041c6 --- /dev/null +++ b/src/state/models/feeds/multi-feed.ts @@ -0,0 +1,216 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AtUri} from '@atproto/api' +import {bundleAsync} from 'lib/async/bundle' +import {RootStoreModel} from '../root-store' +import {CustomFeedModel} from './custom-feed' +import {PostsFeedModel} from './posts' +import {PostsFeedSliceModel} from './post' + +const FEED_PAGE_SIZE = 5 +const FEEDS_PAGE_SIZE = 3 + +export type MultiFeedItem = + | { + _reactKey: string + type: 'header' + } + | { + _reactKey: string + type: 'feed-header' + avatar: string | undefined + title: string + } + | { + _reactKey: string + type: 'feed-slice' + slice: PostsFeedSliceModel + } + | { + _reactKey: string + type: 'feed-loading' + } + | { + _reactKey: string + type: 'feed-error' + error: string + } + | { + _reactKey: string + type: 'feed-footer' + title: string + uri: string + } + | { + _reactKey: string + type: 'footer' + } + +export class PostsMultiFeedModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + hasMore = true + + // data + feedInfos: CustomFeedModel[] = [] + feeds: PostsFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this, {rootStore: false}, {autoBind: true}) + } + + get hasContent() { + return this.feeds.length !== 0 + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get items() { + const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] + for (let i = 0; i < this.feedInfos.length; i++) { + if (!this.feeds[i]) { + break + } + const feed = this.feeds[i] + const feedInfo = this.feedInfos[i] + const urip = new AtUri(feedInfo.uri) + items.push({ + _reactKey: `__feed_header_${i}__`, + type: 'feed-header', + avatar: feedInfo.data.avatar, + title: feedInfo.displayName, + }) + if (feed.isLoading) { + items.push({ + _reactKey: `__feed_loading_${i}__`, + type: 'feed-loading', + }) + } else if (feed.hasError) { + items.push({ + _reactKey: `__feed_error_${i}__`, + type: 'feed-error', + error: feed.error, + }) + } else { + for (let j = 0; j < feed.slices.length; j++) { + items.push({ + _reactKey: `__feed_slice_${i}_${j}__`, + type: 'feed-slice', + slice: feed.slices[j], + }) + } + } + items.push({ + _reactKey: `__feed_footer_${i}__`, + type: 'feed-footer', + title: feedInfo.displayName, + uri: `/profile/${feedInfo.data.creator.did}/feed/${urip.rkey}`, + }) + } + if (!this.hasMore) { + items.push({_reactKey: '__footer__', type: 'footer'}) + } + return items + } + + // public api + // = + + /** + * Nuke all data + */ + clear() { + this.rootStore.log.debug('MultiFeedModel:clear') + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.hasMore = true + this.feeds = [] + } + + /** + * 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() { + this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds + await this.loadMore(true) + } + + /** + * Load more posts to the end of the feed + */ + loadMore = bundleAsync(async (isRefreshing: boolean = false) => { + if (!isRefreshing && !this.hasMore) { + return + } + if (isRefreshing) { + this.isRefreshing = true // set optimistically for UI + this.feeds = [] + } + this._xLoading(isRefreshing) + const start = this.feeds.length + const newFeeds: PostsFeedModel[] = [] + for ( + let i = start; + i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; + i++ + ) { + const feed = new PostsFeedModel(this.rootStore, 'custom', { + feed: this.feedInfos[i].uri, + }) + feed.pageSize = FEED_PAGE_SIZE + await feed.setup() + newFeeds.push(feed) + } + runInAction(() => { + this.feeds = this.feeds.concat(newFeeds) + this.hasMore = this.feeds.length < this.feedInfos.length + }) + this._xIdle() + }) + + /** + * Attempt to load more again after a failure + */ + async retryLoadMore() { + this.hasMore = true + return this.loadMore() + } + + /** + * Removes posts from the feed upon deletion. + */ + onPostDeleted(uri: string) { + for (const f of this.feeds) { + f.onPostDeleted(uri) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + } + + _xIdle() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + } + + // helper functions + // = +} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 73424f03e..5005f1d91 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -181,7 +181,7 @@ export class NotificationsFeedItemModel { return false } - get additionaDataUri(): string | undefined { + get additionalDataUri(): string | undefined { if (this.isReply || this.isQuote || this.isMention) { return this.uri } else if (this.isLike || this.isRepost) { @@ -290,7 +290,9 @@ export class NotificationsFeedModel { } get hasNewLatest() { - return this.queuedNotifications && this.queuedNotifications?.length > 0 + return Boolean( + this.queuedNotifications && this.queuedNotifications?.length > 0, + ) } get unreadCountLabel(): string { @@ -490,7 +492,7 @@ export class NotificationsFeedModel { 'mostRecent', res.data.notifications[0], ) - const addedUri = notif.additionaDataUri + const addedUri = notif.additionalDataUri if (addedUri) { const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ uris: [addedUri], @@ -583,7 +585,7 @@ export class NotificationsFeedModel { `item-${_idCounter++}`, item, ) - const uri = itemModel.additionaDataUri + const uri = itemModel.additionalDataUri if (uri) { const models = addedPostMap.get(uri) || [] models.push(itemModel) diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts new file mode 100644 index 000000000..0c411d448 --- /dev/null +++ b/src/state/models/feeds/post.ts @@ -0,0 +1,265 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedDefs, AppBskyFeedPost, RichText} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {updateDataOptimistically} from 'lib/async/revertible' +import {PostLabelInfo, PostModeration} from 'lib/labeling/types' +import {FeedViewPostsSlice} from 'lib/api/feed-manip' +import { + getEmbedLabels, + getEmbedMuted, + getEmbedMutedByList, + getEmbedBlocking, + getEmbedBlockedBy, + getPostModeration, + filterAccountLabels, + filterProfileLabels, + mergePostModerations, +} from 'lib/labeling/helpers' + +type FeedViewPost = AppBskyFeedDefs.FeedViewPost +type ReasonRepost = AppBskyFeedDefs.ReasonRepost +type PostView = AppBskyFeedDefs.PostView + +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}) + } + + get rootUri(): string { + if (this.reply?.root.uri) { + return this.reply.root.uri + } + return this.post.uri + } + + get isThreadMuted() { + return this.rootStore.mutedThreads.uris.has(this.rootUri) + } + + get labelInfo(): PostLabelInfo { + return { + postLabels: (this.post.labels || []).concat( + getEmbedLabels(this.post.embed), + ), + accountLabels: filterAccountLabels(this.post.author.labels), + profileLabels: filterProfileLabels(this.post.author.labels), + isMuted: + this.post.author.viewer?.muted || + getEmbedMuted(this.post.embed) || + false, + mutedByList: + this.post.author.viewer?.mutedByList || + getEmbedMutedByList(this.post.embed), + isBlocking: + !!this.post.author.viewer?.blocking || + getEmbedBlocking(this.post.embed) || + false, + isBlockedBy: + !!this.post.author.viewer?.blockedBy || + getEmbedBlockedBy(this.post.embed) || + false, + } + } + + get moderation(): PostModeration { + return getPostModeration(this.rootStore, this.labelInfo) + } + + 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.defs#reasonRepost') { + return this.reason as ReasonRepost + } + } + + async toggleLike() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer.like) { + const url = this.post.viewer.like + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) - 1 + this.post.viewer!.like = undefined + }, + () => this.rootStore.agent.deleteLike(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) + 1 + this.post.viewer!.like = 'pending' + }, + () => this.rootStore.agent.like(this.post.uri, this.post.cid), + res => { + this.post.viewer!.like = res.uri + }, + ) + } + } + + async toggleRepost() { + this.post.viewer = this.post.viewer || {} + if (this.post.viewer?.repost) { + const url = this.post.viewer.repost + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) - 1 + this.post.viewer!.repost = undefined + }, + () => this.rootStore.agent.deleteRepost(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) + 1 + this.post.viewer!.repost = 'pending' + }, + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), + res => { + this.post.viewer!.repost = res.uri + }, + ) + } + } + + async toggleThreadMute() { + if (this.isThreadMuted) { + this.rootStore.mutedThreads.uris.delete(this.rootUri) + } else { + this.rootStore.mutedThreads.uris.add(this.rootUri) + } + } + + 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, `slice-${_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] + } + + get moderation() { + return mergePostModerations(this.items.map(item => item.moderation)) + } + + 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 + } +} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index b2dffdc69..911cc6309 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -1,11 +1,8 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedDefs, - AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, - RichText, - jsonToLex, + AppBskyFeedGetFeed as GetCustomFeed, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -19,269 +16,11 @@ import { mergePosts, } from 'lib/api/build-suggested-posts' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' -import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - getPostModeration, - mergePostModerations, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' - -type FeedViewPost = AppBskyFeedDefs.FeedViewPost -type ReasonRepost = AppBskyFeedDefs.ReasonRepost -type PostView = AppBskyFeedDefs.PostView +import {PostsFeedSliceModel} from './post' 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}) - } - - get rootUri(): string { - if (this.reply?.root.uri) { - return this.reply.root.uri - } - return this.post.uri - } - - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - get labelInfo(): PostLabelInfo { - return { - postLabels: (this.post.labels || []).concat( - getEmbedLabels(this.post.embed), - ), - accountLabels: filterAccountLabels(this.post.author.labels), - profileLabels: filterProfileLabels(this.post.author.labels), - isMuted: - this.post.author.viewer?.muted || - getEmbedMuted(this.post.embed) || - false, - mutedByList: - this.post.author.viewer?.mutedByList || - getEmbedMutedByList(this.post.embed), - isBlocking: - !!this.post.author.viewer?.blocking || - getEmbedBlocking(this.post.embed) || - false, - isBlockedBy: - !!this.post.author.viewer?.blockedBy || - getEmbedBlockedBy(this.post.embed) || - false, - } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) - } - - 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.defs#reasonRepost') { - return this.reason as ReasonRepost - } - } - - async toggleLike() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer.like) { - const url = this.post.viewer.like - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) - 1 - this.post.viewer!.like = undefined - }, - () => this.rootStore.agent.deleteLike(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) + 1 - this.post.viewer!.like = 'pending' - }, - () => this.rootStore.agent.like(this.post.uri, this.post.cid), - res => { - this.post.viewer!.like = res.uri - }, - ) - } - } - - async toggleRepost() { - this.post.viewer = this.post.viewer || {} - if (this.post.viewer?.repost) { - const url = this.post.viewer.repost - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) - 1 - this.post.viewer!.repost = undefined - }, - () => this.rootStore.agent.deleteRepost(url), - ) - } else { - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) + 1 - this.post.viewer!.repost = 'pending' - }, - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), - res => { - this.post.viewer!.repost = res.uri - }, - ) - } - } - - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - - 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] - } - - get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) - } - - 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 @@ -297,6 +36,7 @@ export class PostsFeedModel { loadMoreCursor: string | undefined pollCursor: string | undefined tuner = new FeedTuner() + pageSize = PAGE_SIZE // used to linearize async modifications to state lock = new AwaitLock() @@ -309,8 +49,11 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff', - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams, + public feedType: 'home' | 'author' | 'suggested' | 'custom', + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, ) { makeAutoObservable( this, @@ -387,10 +130,9 @@ export class PostsFeedModel { } get feedTuners() { - if (this.feedType === 'goodstuff') { + if (this.feedType === 'custom') { return [ FeedTuner.dedupReposts, - FeedTuner.likedRepliesOnly, FeedTuner.preferredLangOnly( this.rootStore.preferences.contentLanguages, ), @@ -416,7 +158,7 @@ export class PostsFeedModel { this.tuner.reset() this._xLoading(isRefreshing) try { - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -455,7 +197,7 @@ export class PostsFeedModel { try { const res = await this._getFeed({ cursor: this.loadMoreCursor, - limit: PAGE_SIZE, + limit: this.pageSize, }) await this._appendAll(res) this._xIdle() @@ -524,7 +266,7 @@ export class PostsFeedModel { if (this.hasNewLatest || this.feedType === 'suggested') { return } - const res = await this._getFeed({limit: PAGE_SIZE}) + const res = await this._getFeed({limit: this.pageSize}) const tuner = new FeedTuner() const slices = tuner.tune(res.data.feed, this.feedTuners) this.setHasNewLatest(slices[0]?.uri !== this.slices[0]?.uri) @@ -599,13 +341,15 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + async _replaceAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { this.pollCursor = res.data.feed[0]?.post.uri return this._appendAll(res, true) } async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response, + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, replace = false, ) { this.loadMoreCursor = res.data.cursor @@ -644,7 +388,9 @@ export class PostsFeedModel { }) } - _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) { + _updateAll( + res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, + ) { for (const item of res.data.feed) { const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), @@ -661,8 +407,13 @@ export class PostsFeedModel { } protected async _getFeed( - params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {}, - ): Promise<GetTimeline.Response | GetAuthorFeed.Response> { + params: + | GetTimeline.QueryParams + | GetAuthorFeed.QueryParams + | GetCustomFeed.QueryParams, + ): Promise< + GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response + > { params = Object.assign({}, this.params, params) if (this.feedType === 'suggested') { const responses = await getMultipleAuthorsPosts( @@ -684,61 +435,31 @@ export class PostsFeedModel { } } 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, + } else if (this.feedType === 'custom') { + this.checkIfCustomFeedIsOnlineAndValid( + params as GetCustomFeed.QueryParams, ) - res.data.feed = (res.data.feed || []).filter( - item => !item.post.author.viewer?.muted, + return this.rootStore.agent.app.bsky.feed.getFeed( + params as GetCustomFeed.QueryParams, ) - 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<GetTimeline.Response> { - 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])) + private async checkIfCustomFeedIsOnlineAndValid( + params: GetCustomFeed.QueryParams, + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: params.feed, + }) + if (!res.data.isOnline || !res.data.isValid) { + runInAction(() => { + this.error = + 'This custom feed is not online or may be experiencing issues.' + }) } } - - const res = await fetch(String(uri), { - method: 'get', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessJwt}`, - }, - signal: controller.signal, - }) - - const resHeaders: Record<string, string> = {} - 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/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts new file mode 100644 index 000000000..0f2060581 --- /dev/null +++ b/src/state/models/lists/actor-feeds.ts @@ -0,0 +1,120 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +const PAGE_SIZE = 30 + +export class ActorFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: CustomFeedModel[] = [] + + constructor( + public rootStore: RootStoreModel, + public params: GetActorFeeds.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ + actor: this.params.actor, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + 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: GetActorFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetActorFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new CustomFeedModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/log.ts b/src/state/models/log.ts index d80617139..7c9c37c0d 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -27,6 +27,7 @@ function genId(): string { export class LogModel { entries: LogEntry[] = [] + timers = new Map<string, number>() constructor() { makeAutoObservable(this) @@ -74,6 +75,21 @@ export class LogModel { ts: Date.now(), }) } + + time = (label = 'default') => { + this.timers.set(label, performance.now()) + } + + timeEnd = (label = 'default', warn = false) => { + const endTime = performance.now() + if (this.timers.has(label)) { + const elapsedTime = endTime - this.timers.get(label)! + console.log(`${label}: ${elapsedTime.toFixed(3)}ms`) + this.timers.delete(label) + } else { + warn && console.warn(`Timer with label '${label}' does not exist.`) + } + } } function detailsToStr(details?: any) { diff --git a/src/state/models/me.ts b/src/state/models/me.ts index ba2dc6f32..815044857 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,6 +8,7 @@ import {PostsFeedModel} from './feeds/posts' import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' +import {SavedFeedsModel} from './ui/saved-feeds' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -21,6 +22,7 @@ export class MeModel { followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel + savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -43,12 +45,14 @@ export class MeModel { }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) + this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { this.mainFeed.clear() this.notifications.clear() this.follows.clear() + this.savedFeeds.clear() this.did = '' this.handle = '' this.displayName = '' @@ -110,6 +114,7 @@ export class MeModel { /* dont await */ this.notifications.setup().catch(e => { this.rootStore.log.error('Failed to setup notifications model', e) }) + /* dont await */ this.savedFeeds.refresh(true) this.rootStore.emitSessionLoaded() await this.fetchInviteCodes() await this.fetchAppPasswords() @@ -119,6 +124,7 @@ export class MeModel { } async updateIfNeeded() { + /* dont await */ this.savedFeeds.refresh(true) if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { this.rootStore.log.debug('Updating me profile information') this.lastProfileStateUpdate = Date.now() diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 67f8d2ea1..52ef8f375 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -4,7 +4,6 @@ import {ImageModel} from './image' import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' import {getImageDim} from 'lib/media/manip' -import {getDataUriSize} from 'lib/media/util' import {isNative} from 'platform/detection' export class GalleryModel { @@ -24,13 +23,7 @@ export class GalleryModel { return this.images.length } - get paths() { - return this.images.map(image => - image.compressed === undefined ? image.path : image.compressed.path, - ) - } - - async add(image_: RNImage) { + async add(image_: Omit<RNImage, 'size'>) { if (this.size >= 4) { return } @@ -39,15 +32,9 @@ export class GalleryModel { if (!this.images.some(i => i.path === image_.path)) { const image = new ImageModel(this.rootStore, image_) - if (!isNative) { - await image.manipulate({}) - } else { - await image.compress() - } - - runInAction(() => { - this.images.push(image) - }) + // Initial resize + image.manipulate({}) + this.images.push(image) } } @@ -70,11 +57,10 @@ export class GalleryModel { const {width, height} = await getImageDim(uri) - const image: RNImage = { + const image = { path: uri, height, width, - size: getDataUriSize(uri), mime: 'image/jpeg', } diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ec93bf5b6..e524c49de 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -3,14 +3,11 @@ import {RootStoreModel} from 'state/index' import {makeAutoObservable, runInAction} from 'mobx' import {POST_IMG_MAX} from 'lib/constants' import * as ImageManipulator from 'expo-image-manipulator' -import {getDataUriSize, scaleDownDimensions} from 'lib/media/util' +import {getDataUriSize} from 'lib/media/util' import {openCropper} from 'lib/media/picker' import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' import {Position} from 'react-avatar-editor' -import {compressAndResizeImageForPost} from 'lib/media/manip' - -// TODO: EXIF embed -// Cases to consider: ExternalEmbed +import {Dimensions} from 'lib/media/types' export interface ImageManipulationAttributes { aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' @@ -21,17 +18,16 @@ export interface ImageManipulationAttributes { flipVertical?: boolean } -export class ImageModel implements RNImage { +const MAX_IMAGE_SIZE_IN_BYTES = 976560 + +export class ImageModel implements Omit<RNImage, 'size'> { path: string mime = 'image/jpeg' width: number height: number - size: number altText = '' cropped?: RNImage = undefined compressed?: RNImage = undefined - scaledWidth: number = POST_IMG_MAX.width - scaledHeight: number = POST_IMG_MAX.height // Web manipulation prev?: RNImage @@ -44,7 +40,7 @@ export class ImageModel implements RNImage { } prevAttributes: ImageManipulationAttributes = {} - constructor(public rootStore: RootStoreModel, image: RNImage) { + constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) { makeAutoObservable(this, { rootStore: false, }) @@ -52,19 +48,8 @@ export class ImageModel implements RNImage { this.path = image.path this.width = image.width this.height = image.height - this.size = image.size - this.calcScaledDimensions() } - // TODO: Revisit compression factor due to updated sizing with zoom - // get compressionFactor() { - // const MAX_IMAGE_SIZE_IN_BYTES = 976560 - - // return this.size < MAX_IMAGE_SIZE_IN_BYTES - // ? 1 - // : MAX_IMAGE_SIZE_IN_BYTES / this.size - // } - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { this.attributes.aspectRatio = aspectRatio } @@ -93,8 +78,24 @@ export class ImageModel implements RNImage { } } - getDisplayDimensions( - as: ImageManipulationAttributes['aspectRatio'] = '1:1', + getUploadDimensions( + dimensions: Dimensions, + maxDimensions: Dimensions = POST_IMG_MAX, + as: ImageManipulationAttributes['aspectRatio'] = 'None', + ) { + const {width, height} = dimensions + const {width: maxWidth, height: maxHeight} = maxDimensions + + return width < maxWidth && height < maxHeight + ? { + width, + height, + } + : this.getResizedDimensions(as, POST_IMG_MAX.width) + } + + getResizedDimensions( + as: ImageManipulationAttributes['aspectRatio'] = 'None', maxSide: number, ) { const ratioMultiplier = this.ratioMultipliers[as] @@ -119,59 +120,70 @@ export class ImageModel implements RNImage { } } - calcScaledDimensions() { - const {width, height} = scaleDownDimensions( - {width: this.width, height: this.height}, - POST_IMG_MAX, - ) - this.scaledWidth = width - this.scaledHeight = height - } - async setAltText(altText: string) { this.altText = altText } - // Only for mobile - async crop() { - try { - const cropped = await openCropper({ - mediaType: 'photo', - path: this.path, - freeStyleCropEnabled: true, - width: this.scaledWidth, - height: this.scaledHeight, - }) - runInAction(() => { - this.cropped = cropped - this.compress() - }) - } catch (err) { - this.rootStore.log.error('Failed to crop photo', err) + // Only compress prior to upload + async compress() { + for (let i = 10; i > 0; i--) { + // Float precision + const factor = Math.round(i) / 10 + const compressed = await ImageManipulator.manipulateAsync( + this.cropped?.path ?? this.path, + undefined, + { + compress: factor, + base64: true, + format: SaveFormat.JPEG, + }, + ) + + if (compressed.base64 !== undefined) { + const size = getDataUriSize(compressed.base64) + + if (size < MAX_IMAGE_SIZE_IN_BYTES) { + runInAction(() => { + this.compressed = { + mime: 'image/jpeg', + path: compressed.uri, + size, + ...compressed, + } + }) + return + } + } } + + // Compression fails when removing redundant information is not possible. + // This can be tested with images that have high variance in noise. + throw new Error('Failed to compress image') } - async compress() { + // Mobile + async crop() { try { - const {width, height} = scaleDownDimensions( - this.cropped - ? {width: this.cropped.width, height: this.cropped.height} - : {width: this.width, height: this.height}, - POST_IMG_MAX, - ) + // openCropper requires an output width and height hence + // getting upload dimensions before cropping is necessary. + const {width, height} = this.getUploadDimensions({ + width: this.width, + height: this.height, + }) - // TODO: Revisit this - currently iOS uses this as well - const compressed = await compressAndResizeImageForPost({ - ...(this.cropped === undefined ? this : this.cropped), + const cropped = await openCropper(this.rootStore, { + mediaType: 'photo', + path: this.path, + freeStyleCropEnabled: true, width, height, }) runInAction(() => { - this.compressed = compressed + this.cropped = cropped }) } catch (err) { - this.rootStore.log.error('Failed to compress photo', err) + this.rootStore.log.error('Failed to crop photo', err) } } @@ -181,6 +193,9 @@ export class ImageModel implements RNImage { crop?: ActionCrop['crop'] } & ImageManipulationAttributes, ) { + let uploadWidth: number | undefined + let uploadHeight: number | undefined + const {aspectRatio, crop, position, scale} = attributes const modifiers = [] @@ -197,14 +212,34 @@ export class ImageModel implements RNImage { } if (crop !== undefined) { + const croppedHeight = crop.height * this.height + const croppedWidth = crop.width * this.width modifiers.push({ crop: { originX: crop.originX * this.width, originY: crop.originY * this.height, - height: crop.height * this.height, - width: crop.width * this.width, + height: croppedHeight, + width: croppedWidth, }, }) + + const uploadDimensions = this.getUploadDimensions( + {width: croppedWidth, height: croppedHeight}, + POST_IMG_MAX, + aspectRatio, + ) + + uploadWidth = uploadDimensions.width + uploadHeight = uploadDimensions.height + } else { + const uploadDimensions = this.getUploadDimensions( + {width: this.width, height: this.height}, + POST_IMG_MAX, + aspectRatio, + ) + + uploadWidth = uploadDimensions.width + uploadHeight = uploadDimensions.height } if (scale !== undefined) { @@ -222,36 +257,40 @@ export class ImageModel implements RNImage { const ratioMultiplier = this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] - const MAX_SIDE = 2000 - const result = await ImageManipulator.manipulateAsync( this.path, [ ...modifiers, - {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}}, + { + resize: + ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, + }, ], { - compress: 0.9, + base64: true, format: SaveFormat.JPEG, }, ) runInAction(() => { - this.compressed = { + this.cropped = { mime: 'image/jpeg', path: result.uri, - size: getDataUriSize(result.uri), + size: + result.base64 !== undefined + ? getDataUriSize(result.base64) + : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails ...result, } }) } - resetCompressed() { + resetCropped() { this.manipulate({}) } previous() { - this.compressed = this.prev + this.cropped = this.prev this.attributes = this.prevAttributes } } diff --git a/src/state/models/session.ts b/src/state/models/session.ts index c36537601..aa9c97750 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -187,7 +187,7 @@ export class SessionModel { account => account.service === service && account.did === did, ) - // fall back to any pre-existing access tokens + // fall back to any preexisting access tokens let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt let accessJwt = session?.accessJwt || existingAccount?.accessJwt if (event === 'expired') { @@ -247,7 +247,7 @@ export class SessionModel { const res = await agent.getProfile({actor: did}).catch(_e => undefined) if (res) { return { - dispayName: res.data.displayName, + displayName: res.data.displayName, aviUrl: res.data.avatar, } } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 1471420fc..dcf6b9a7a 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -11,6 +11,7 @@ import { ALWAYS_FILTER_LABEL_GROUP, ALWAYS_WARN_LABEL_GROUP, } from 'lib/labeling/const' +import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS} from 'platform/detection' const deviceLocales = getLocales() @@ -25,6 +26,7 @@ const LABEL_GROUPS = [ 'spam', 'impersonation', ] +const VISIBILITY_VALUES = ['show', 'warn', 'hide'] export class LabelPreferencesModel { nsfw: LabelPreference = 'hide' @@ -45,6 +47,8 @@ export class PreferencesModel { contentLanguages: string[] = deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() + savedFeeds: string[] = [] + pinnedFeeds: string[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable(this, {}, {autoBind: true}) @@ -54,9 +58,16 @@ export class PreferencesModel { return { contentLanguages: this.contentLanguages, contentLabels: this.contentLabels, + savedFeeds: this.savedFeeds, + pinnedFeeds: this.pinnedFeeds, } } + /** + * The function hydrates an object with properties related to content languages, labels, saved feeds, + * and pinned feeds that it gets from the parameter `v` (probably local storage) + * @param {unknown} v - the data object to hydrate from + */ hydrate(v: unknown) { if (isObj(v)) { if ( @@ -72,10 +83,29 @@ export class PreferencesModel { // default to the device languages this.contentLanguages = deviceLocales.map(locale => locale.languageCode) } + if ( + hasProp(v, 'savedFeeds') && + Array.isArray(v.savedFeeds) && + typeof v.savedFeeds.every(item => typeof item === 'string') + ) { + this.savedFeeds = v.savedFeeds + } + if ( + hasProp(v, 'pinnedFeeds') && + Array.isArray(v.pinnedFeeds) && + typeof v.pinnedFeeds.every(item => typeof item === 'string') + ) { + this.pinnedFeeds = v.pinnedFeeds + } } } + /** + * This function fetches preferences and sets defaults for missing items. + */ async sync() { + // fetch preferences + let hasSavedFeedsPref = false const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) runInAction(() => { for (const pref of res.data.preferences) { @@ -88,22 +118,83 @@ export class PreferencesModel { AppBskyActorDefs.isContentLabelPref(pref) && AppBskyActorDefs.validateAdultContentPref(pref).success ) { - if (LABEL_GROUPS.includes(pref.label)) { - this.contentLabels[pref.label] = pref.visibility + if ( + LABEL_GROUPS.includes(pref.label) && + VISIBILITY_VALUES.includes(pref.visibility) + ) { + this.contentLabels[pref.label as keyof LabelPreferencesModel] = + pref.visibility as LabelPreference } + } else if ( + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success + ) { + this.savedFeeds = pref.saved + this.pinnedFeeds = pref.pinned + hasSavedFeedsPref = true } } }) + + // set defaults on missing items + if (!hasSavedFeedsPref) { + const {saved, pinned} = await DEFAULT_FEEDS( + this.rootStore.agent.service.toString(), + (handle: string) => + this.rootStore.agent + .resolveHandle({handle}) + .then(({data}) => data.did), + ) + runInAction(() => { + this.savedFeeds = saved + this.pinnedFeeds = pinned + }) + res.data.preferences.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + /* dont await */ this.rootStore.me.savedFeeds.refresh() + } } - async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + /** + * This function updates the preferences of a user and allows for a callback function to be executed + * before the update. + * @param cb - cb is a callback function that takes in a single parameter of type + * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to + * update the preferences of the user. The function is called with the current preferences as an + * argument and if the callback returns false, the preferences are not updated. + * @returns void + */ + async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - cb(res.data.preferences) + if (cb(res.data.preferences) === false) { + return + } await this.rootStore.agent.app.bsky.actor.putPreferences({ preferences: res.data.preferences, }) } + /** + * This function resets the preferences to an empty array of no preferences. + */ + async reset() { + runInAction(() => { + this.contentLabels = new LabelPreferencesModel() + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) + this.savedFeeds = [] + this.pinnedFeeds = [] + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -200,4 +291,62 @@ export class PreferencesModel { } return res } + + setFeeds(saved: string[], pinned: string[]) { + this.savedFeeds = saved + this.pinnedFeeds = pinned + } + + async setSavedFeeds(saved: string[], pinned: string[]) { + const oldSaved = this.savedFeeds + const oldPinned = this.pinnedFeeds + this.setFeeds(saved, pinned) + try { + await this.update((prefs: AppBskyActorDefs.Preferences) => { + const existing = prefs.find( + pref => + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success, + ) + if (existing) { + existing.saved = saved + existing.pinned = pinned + } else { + prefs.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + } + }) + } catch (e) { + runInAction(() => { + this.savedFeeds = oldSaved + this.pinnedFeeds = oldPinned + }) + throw e + } + } + + async addSavedFeed(v: string) { + return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds) + } + + async removeSavedFeed(v: string) { + return this.setSavedFeeds( + this.savedFeeds.filter(uri => uri !== v), + this.pinnedFeeds.filter(uri => uri !== v), + ) + } + + async addPinnedFeed(v: string) { + return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v]) + } + + async removePinnedFeed(v: string) { + return this.setSavedFeeds( + this.savedFeeds, + this.pinnedFeeds.filter(uri => uri !== v), + ) + } } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 861b3df0e..81daf797f 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,20 +2,16 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' +import {ActorFeedsModel} from '../lists/actor-feeds' import {ListsListModel} from '../lists/lists-list' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', + CustomAlgorithms = 'Feeds', Lists = 'Lists', } -const USER_SELECTOR_ITEMS = [ - Sections.Posts, - Sections.PostsWithReplies, - Sections.Lists, -] - export interface ProfileUiParams { user: string } @@ -28,6 +24,7 @@ export class ProfileUiModel { // data profile: ProfileModel feed: PostsFeedModel + algos: ActorFeedsModel lists: ListsListModel // ui state @@ -50,10 +47,11 @@ export class ProfileUiModel { actor: params.user, limit: 10, }) + this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) this.lists = new ListsListModel(rootStore, params.user) } - get currentView(): PostsFeedModel | ListsListModel { + get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies @@ -62,6 +60,9 @@ export class ProfileUiModel { } else if (this.selectedView === Sections.Lists) { return this.lists } + if (this.selectedView === Sections.CustomAlgorithms) { + return this.algos + } throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) } @@ -75,7 +76,14 @@ export class ProfileUiModel { } get selectorItems() { - return USER_SELECTOR_ITEMS + const items = [Sections.Posts, Sections.PostsWithReplies] + if (this.algos.hasLoaded && !this.algos.isEmpty) { + items.push(Sections.CustomAlgorithms) + } + if (this.lists.hasLoaded && !this.lists.isEmpty) { + items.push(Sections.Lists) + } + return items } get selectedView() { @@ -84,9 +92,11 @@ export class ProfileUiModel { get uiItems() { let arr: any[] = [] + // if loading, return loading item to show loading spinner if (this.isInitialLoading) { arr = arr.concat([ProfileUiModel.LOADING_ITEM]) } else if (this.currentView.hasError) { + // if error, return error item to show error message arr = arr.concat([ { _reactKey: '__error__', @@ -94,12 +104,16 @@ export class ProfileUiModel { }, ]) } else { + // not loading, no error, show content if ( this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.CustomAlgorithms ) { if (this.feed.hasContent) { - if (this.selectedView === Sections.Posts) { + if (this.selectedView === Sections.CustomAlgorithms) { + arr = this.algos.feeds + } else if (this.selectedView === Sections.Posts) { arr = this.feed.nonReplyFeed } else { arr = this.feed.slices.slice() @@ -117,6 +131,7 @@ export class ProfileUiModel { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } else { + // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } @@ -151,6 +166,7 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + this.algos.refresh() // HACK: need to use the DID as a param, not the username -prf this.lists.source = this.profile.did this.lists diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts new file mode 100644 index 000000000..979fddf49 --- /dev/null +++ b/src/state/models/ui/saved-feeds.ts @@ -0,0 +1,185 @@ +import {makeAutoObservable, runInAction} from 'mobx' +import {AppBskyFeedDefs} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {CustomFeedModel} from '../feeds/custom-feed' + +export class SavedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + + // data + feeds: CustomFeedModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + get pinned() { + return this.rootStore.preferences.pinnedFeeds + .map(uri => this.feeds.find(f => f.uri === uri) as CustomFeedModel) + .filter(Boolean) + } + + get unpinned() { + return this.feeds.filter(f => !this.isPinned(f)) + } + + get all() { + return this.pinned.concat(this.unpinned) + } + + get pinnedFeedNames() { + return this.pinned.map(f => f.displayName) + } + + // public api + // = + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.feeds = [] + } + + refresh = bundleAsync(async (quietRefresh = false) => { + this._xLoading(!quietRefresh) + try { + let feeds: AppBskyFeedDefs.GeneratorView[] = [] + for ( + let i = 0; + i < this.rootStore.preferences.savedFeeds.length; + i += 25 + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ + feeds: this.rootStore.preferences.savedFeeds.slice(i, 25), + }) + feeds = feeds.concat(res.data.feeds) + } + runInAction(() => { + this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f)) + }) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + async save(feed: CustomFeedModel) { + try { + await feed.save() + runInAction(() => { + this.feeds = [ + ...this.feeds, + new CustomFeedModel(this.rootStore, feed.data), + ] + }) + } catch (e: any) { + this.rootStore.log.error('Failed to save feed', e) + } + } + + async unsave(feed: CustomFeedModel) { + const uri = feed.uri + try { + if (this.isPinned(feed)) { + await this.rootStore.preferences.removePinnedFeed(uri) + } + await feed.unsave() + runInAction(() => { + this.feeds = this.feeds.filter(f => f.data.uri !== uri) + }) + } catch (e: any) { + this.rootStore.log.error('Failed to unsave feed', e) + } + } + + async togglePinnedFeed(feed: CustomFeedModel) { + if (!this.isPinned(feed)) { + return this.rootStore.preferences.addPinnedFeed(feed.uri) + } else { + return this.rootStore.preferences.removePinnedFeed(feed.uri) + } + } + + async reorderPinnedFeeds(feeds: CustomFeedModel[]) { + return this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, + feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), + ) + } + + isPinned(feedOrUri: CustomFeedModel | string) { + let uri: string + if (typeof feedOrUri === 'string') { + uri = feedOrUri + } else { + uri = feedOrUri.uri + } + return this.rootStore.preferences.pinnedFeeds.includes(uri) + } + + async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') { + const pinned = this.rootStore.preferences.pinnedFeeds.slice() + const index = pinned.indexOf(item.uri) + if (index === -1) { + return + } + if (direction === 'up' && index !== 0) { + const temp = pinned[index] + pinned[index] = pinned[index - 1] + pinned[index - 1] = temp + } else if (direction === 'down' && index < pinned.length - 1) { + const temp = pinned[index] + pinned[index] = pinned[index + 1] + pinned[index + 1] = temp + } + await this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, + pinned, + ) + } + + // 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 feeds', err) + } + } +} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 187342ec3..a77ffbdfb 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -119,7 +119,7 @@ export type Modal = // Moderation | ReportAccountModal | ReportPostModal - | CreateMuteListModal + | CreateOrEditMuteListModal | ListAddRemoveUserModal // Posts |