diff options
Diffstat (limited to 'src/state/models')
-rw-r--r-- | src/state/models/cache/handle-resolutions.ts | 5 | ||||
-rw-r--r-- | src/state/models/cache/posts.ts | 70 | ||||
-rw-r--r-- | src/state/models/cache/profiles-view.ts | 4 | ||||
-rw-r--r-- | src/state/models/content/list.ts | 2 | ||||
-rw-r--r-- | src/state/models/content/post-thread-item.ts | 8 | ||||
-rw-r--r-- | src/state/models/content/post-thread.ts | 101 | ||||
-rw-r--r-- | src/state/models/content/profile.ts | 58 | ||||
-rw-r--r-- | src/state/models/discovery/foafs.ts | 29 | ||||
-rw-r--r-- | src/state/models/discovery/suggested-actors.ts | 22 | ||||
-rw-r--r-- | src/state/models/feeds/notifications.ts | 77 | ||||
-rw-r--r-- | src/state/models/feeds/post.ts | 46 | ||||
-rw-r--r-- | src/state/models/feeds/posts-slice.ts | 34 | ||||
-rw-r--r-- | src/state/models/feeds/posts.ts | 64 | ||||
-rw-r--r-- | src/state/models/me.ts | 4 | ||||
-rw-r--r-- | src/state/models/media/image.ts | 4 | ||||
-rw-r--r-- | src/state/models/root-store.ts | 4 | ||||
-rw-r--r-- | src/state/models/ui/preferences.ts | 56 | ||||
-rw-r--r-- | src/state/models/ui/profile.ts | 61 | ||||
-rw-r--r-- | src/state/models/ui/shell.ts | 35 |
19 files changed, 459 insertions, 225 deletions
diff --git a/src/state/models/cache/handle-resolutions.ts b/src/state/models/cache/handle-resolutions.ts new file mode 100644 index 000000000..2e2b69661 --- /dev/null +++ b/src/state/models/cache/handle-resolutions.ts @@ -0,0 +1,5 @@ +import {LRUMap} from 'lru_map' + +export class HandleResolutionsCache { + cache: LRUMap<string, string> = new LRUMap(500) +} diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts new file mode 100644 index 000000000..d3632f436 --- /dev/null +++ b/src/state/models/cache/posts.ts @@ -0,0 +1,70 @@ +import {LRUMap} from 'lru_map' +import {RootStoreModel} from '../root-store' +import { + AppBskyFeedDefs, + AppBskyEmbedRecord, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, +} from '@atproto/api' + +type PostView = AppBskyFeedDefs.PostView + +export class PostsCache { + cache: LRUMap<string, PostView> = new LRUMap(500) + + constructor(public rootStore: RootStoreModel) {} + + set(uri: string, postView: PostView) { + this.cache.set(uri, postView) + if (postView.author.handle) { + this.rootStore.handleResolutions.cache.set( + postView.author.handle, + postView.author.did, + ) + } + } + + fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) { + this.set(feedItem.post.uri, feedItem.post) + if ( + feedItem.reply?.parent && + AppBskyFeedDefs.isPostView(feedItem.reply?.parent) + ) { + this.set(feedItem.reply.parent.uri, feedItem.reply.parent) + } + const embed = feedItem.post.embed + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record) && + AppBskyFeedPost.isRecord(embed.record.value) && + AppBskyFeedPost.validateRecord(embed.record.value).success + ) { + this.set(embed.record.uri, embedViewToPostView(embed.record)) + } + if ( + AppBskyEmbedRecordWithMedia.isView(embed) && + AppBskyEmbedRecord.isViewRecord(embed.record?.record) && + AppBskyFeedPost.isRecord(embed.record.record.value) && + AppBskyFeedPost.validateRecord(embed.record.record.value).success + ) { + this.set( + embed.record.record.uri, + embedViewToPostView(embed.record.record), + ) + } + } +} + +function embedViewToPostView( + embedView: AppBskyEmbedRecord.ViewRecord, +): PostView { + return { + $type: 'app.bsky.feed.post#view', + uri: embedView.uri, + cid: embedView.cid, + author: embedView.author, + record: embedView.value, + indexedAt: embedView.indexedAt, + labels: embedView.labels, + } +} diff --git a/src/state/models/cache/profiles-view.ts b/src/state/models/cache/profiles-view.ts index b4bd70db5..e5a9be587 100644 --- a/src/state/models/cache/profiles-view.ts +++ b/src/state/models/cache/profiles-view.ts @@ -45,8 +45,6 @@ export class ProfilesCache { } overwrite(did: string, res: GetProfile.Response) { - if (this.cache.has(did)) { - this.cache.set(did, res) - } + this.cache.set(did, res) } } diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index 2498cf581..5d4ffb4fa 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -306,7 +306,7 @@ export class ListModel { this.hasMore = !!this.loadMoreCursor this.list = res.data.list this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject})), + res.data.items.map(item => ({...item, _reactKey: item.subject.did})), ) } } diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts index 14aa607ed..942f3acc8 100644 --- a/src/state/models/content/post-thread-item.ts +++ b/src/state/models/content/post-thread-item.ts @@ -3,9 +3,9 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' import {PostsFeedItemModel} from '../feeds/post' type PostView = AppBskyFeedDefs.PostView @@ -67,10 +67,6 @@ export class PostThreadItemModel { return this.data.isThreadMuted } - get labelInfo(): PostLabelInfo { - return this.data.labelInfo - } - get moderation(): PostModeration { return this.data.moderation } @@ -111,7 +107,7 @@ export class PostThreadItemModel { const itemModel = new PostThreadItemModel(this.rootStore, item) itemModel._depth = this._depth + 1 itemModel._showParentReplyLine = - itemModel.parentUri !== highlightedPostUri && replies.length === 0 + itemModel.parentUri !== highlightedPostUri if (item.replies?.length) { itemModel._showChildReplyLine = true itemModel.assignTreeModels(item, highlightedPostUri, false, true) diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index 0a67c783e..85ed13cb4 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AppBskyFeedGetPostThread as GetPostThread, AppBskyFeedDefs, + PostModeration, } from '@atproto/api' import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' @@ -12,6 +13,8 @@ import {PostThreadItemModel} from './post-thread-item' export class PostThreadModel { // state isLoading = false + isLoadingFromCache = false + isFromCache = false isRefreshing = false hasLoaded = false error = '' @@ -20,7 +23,7 @@ export class PostThreadModel { params: GetPostThread.QueryParams // data - thread?: PostThreadItemModel + thread?: PostThreadItemModel | null = null isBlocked = false constructor( @@ -52,7 +55,7 @@ export class PostThreadModel { } get hasContent() { - return typeof this.thread !== 'undefined' + return !!this.thread } get hasError() { @@ -82,10 +85,16 @@ export class PostThreadModel { if (!this.resolvedUri) { await this._resolveUri() } + if (this.hasContent) { await this.update() } else { - await this._load() + const precache = this.rootStore.posts.cache.get(this.resolvedUri) + if (precache) { + await this._loadPrecached(precache) + } else { + await this._load() + } } } @@ -169,6 +178,37 @@ export class PostThreadModel { }) } + async _loadPrecached(precache: AppBskyFeedDefs.PostView) { + // start with the cached version + this.isLoadingFromCache = true + this.isFromCache = true + this._replaceAll({ + success: true, + headers: {}, + data: { + thread: { + post: precache, + }, + }, + }) + this._xIdle() + + // then update in the background + try { + const res = await this.rootStore.agent.getPostThread( + Object.assign({}, this.params, {uri: this.resolvedUri}), + ) + this._replaceAll(res) + } catch (e: any) { + console.log(e) + this._xIdle(e) + } finally { + runInAction(() => { + this.isLoadingFromCache = false + }) + } + } + async _load(isRefreshing = false) { if (this.hasLoaded && !isRefreshing) { return @@ -192,7 +232,6 @@ export class PostThreadModel { return } pruneReplies(res.data.thread) - sortThread(res.data.thread) const thread = new PostThreadItemModel( this.rootStore, res.data.thread as AppBskyFeedDefs.ThreadViewPost, @@ -202,6 +241,7 @@ export class PostThreadModel { res.data.thread as AppBskyFeedDefs.ThreadViewPost, thread.uri, ) + sortThread(thread) this.thread = thread } } @@ -223,24 +263,28 @@ function pruneReplies(post: MaybePost) { } } -function sortThread(post: MaybePost) { - if (post.notFound) { +type MaybeThreadItem = + | PostThreadItemModel + | AppBskyFeedDefs.NotFoundPost + | AppBskyFeedDefs.BlockedPost +function sortThread(item: MaybeThreadItem) { + if ('notFound' in item) { return } - post = post as AppBskyFeedDefs.ThreadViewPost - if (post.replies) { - post.replies.sort((a: MaybePost, b: MaybePost) => { - post = post as AppBskyFeedDefs.ThreadViewPost - if (a.notFound) { + item = item as PostThreadItemModel + if (item.replies) { + item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => { + if ('notFound' in a && a.notFound) { return 1 } - if (b.notFound) { + if ('notFound' in b && 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 + item = item as PostThreadItemModel + a = a as PostThreadItemModel + b = b as PostThreadItemModel + const aIsByOp = a.post.author.did === item.post.author.did + const bIsByOp = b.post.author.did === item.post.author.did if (aIsByOp && bIsByOp) { return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest } else if (aIsByOp) { @@ -248,8 +292,31 @@ function sortThread(post: MaybePost) { } else if (bIsByOp) { return 1 // op's own reply } + // put moderated content down at the bottom + if (modScore(a.moderation) !== modScore(b.moderation)) { + return modScore(a.moderation) - modScore(b.moderation) + } return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest }) - post.replies.forEach(reply => sortThread(reply)) + item.replies.forEach(reply => sortThread(reply)) + } +} + +function modScore(mod: PostModeration): number { + if (mod.content.blur && mod.content.noOverride) { + return 5 + } + if (mod.content.blur) { + return 4 + } + if (mod.content.alert) { + return 3 + } + if (mod.embed.blur && mod.embed.noOverride) { + return 2 + } + if (mod.embed.blur) { + return 1 } + return 0 } diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 34b2ea28e..26fa6008c 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -6,18 +6,14 @@ import { AppBskyActorGetProfile as GetProfile, AppBskyActorProfile, RichText, + moderateProfile, + ProfileModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {FollowState} from '../cache/my-follows' import {Image as RNImage} from 'react-native-image-crop-picker' -import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types' -import { - getProfileModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' export class ProfileViewerModel { @@ -26,7 +22,8 @@ export class ProfileViewerModel { following?: string followedBy?: string blockedBy?: boolean - blocking?: string + blocking?: string; + [key: string]: unknown constructor() { makeAutoObservable(this) @@ -53,7 +50,8 @@ export class ProfileModel { followsCount: number = 0 postsCount: number = 0 labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel() + viewer = new ProfileViewerModel(); + [key: string]: unknown // added data descriptionRichText?: RichText = new RichText({text: ''}) @@ -85,25 +83,20 @@ export class ProfileModel { return this.hasLoaded && !this.hasContent } - get labelInfo(): ProfileLabelInfo { - return { - accountLabels: filterAccountLabels(this.labels), - profileLabels: filterProfileLabels(this.labels), - isMuted: this.viewer?.muted || false, - isBlocking: !!this.viewer?.blocking || false, - isBlockedBy: !!this.viewer?.blockedBy || false, - } - } - get moderation(): ProfileModeration { - return getProfileModeration(this.rootStore, this.labelInfo) + return moderateProfile(this, this.rootStore.preferences.moderationOpts) } // public api // = async setup() { - await this._load() + const precache = await this.rootStore.profiles.cache.get(this.params.actor) + if (precache) { + await this._loadWithCache(precache) + } else { + await this._load() + } } async refresh() { @@ -252,7 +245,13 @@ export class ProfileModel { this._xLoading(isRefreshing) try { const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation + this.rootStore.profiles.overwrite(this.params.actor, res) + if (res.data.handle) { + this.rootStore.handleResolutions.cache.set( + res.data.handle, + res.data.did, + ) + } this._replaceAll(res) await this._createRichText() this._xIdle() @@ -261,6 +260,23 @@ export class ProfileModel { } } + async _loadWithCache(precache: GetProfile.Response) { + // use cached value + this._replaceAll(precache) + await this._createRichText() + this._xIdle() + + // fetch latest + try { + const res = await this.rootStore.agent.getProfile(this.params) + this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation + this._replaceAll(res) + await this._createRichText() + } catch (e: any) { + this._xIdle(e) + } + } + _replaceAll(res: GetProfile.Response) { this.did = res.data.did this.handle = res.data.handle diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts index 4b25ed4af..580145f65 100644 --- a/src/state/models/discovery/foafs.ts +++ b/src/state/models/discovery/foafs.ts @@ -1,6 +1,7 @@ import { AppBskyActorDefs, AppBskyGraphGetFollows as GetFollows, + moderateProfile, } from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import sampleSize from 'lodash.samplesize' @@ -52,6 +53,13 @@ export class FoafsModel { cursor, limit: 100, }) + res.data.follows = res.data.follows.filter( + profile => + !moderateProfile( + profile, + this.rootStore.preferences.moderationOpts, + ).account.filter, + ) this.rootStore.me.follows.hydrateProfiles(res.data.follows) if (!res.data.cursor) { break @@ -97,11 +105,24 @@ export class FoafsModel { const profile = profiles.data.profiles[i] const source = this.sources[i] if (res.status === 'fulfilled' && profile) { - // filter out users already followed by the user or that *is* the user + // filter out inappropriate suggestions res.value.data.follows = res.value.data.follows.filter(follow => { - return ( - follow.did !== this.rootStore.me.did && !follow.viewer?.following - ) + const viewer = follow.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } + } + if (follow.did === this.rootStore.me.did) { + return false + } + return true }) runInAction(() => { diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts index 50faae614..0b3d36952 100644 --- a/src/state/models/discovery/suggested-actors.ts +++ b/src/state/models/discovery/suggested-actors.ts @@ -1,5 +1,5 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' @@ -69,7 +69,12 @@ export class SuggestedActorsModel { limit: 25, cursor: this.loadMoreCursor, }) - const {actors, cursor} = res.data + let {actors, cursor} = res.data + actors = actors.filter( + actor => + !moderateProfile(actor, this.rootStore.preferences.moderationOpts) + .account.filter, + ) this.rootStore.me.follows.hydrateProfiles(actors) runInAction(() => { @@ -80,8 +85,17 @@ export class SuggestedActorsModel { this.hasMore = !!cursor this.suggestions = this.suggestions.concat( actors.filter(actor => { - if (actor.viewer?.following) { - return false + const viewer = actor.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } } if (actor.did === this.rootStore.me.did) { return false diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts index 05e2ef0db..50a411379 100644 --- a/src/state/models/feeds/notifications.ts +++ b/src/state/models/feeds/notifications.ts @@ -8,6 +8,8 @@ import { AppBskyFeedLike, AppBskyGraphFollow, ComAtprotoLabelDefs, + moderatePost, + moderateProfile, } from '@atproto/api' import AwaitLock from 'await-lock' import chunk from 'lodash.chunk' @@ -15,24 +17,12 @@ import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {PostThreadModel} from '../content/post-thread' import {cleanError} from 'lib/strings/errors' -import { - PostLabelInfo, - PostModeration, - ModerationBehaviorCode, -} from 'lib/labeling/types' -import { - getPostModeration, - filterAccountLabels, - filterProfileLabels, -} from 'lib/labeling/helpers' 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 const MAX_VISIBLE_NOTIFS = 30 export interface GroupedNotification extends ListNotifications.Notification { @@ -100,27 +90,19 @@ export class NotificationsFeedItemModel { } } - get labelInfo(): PostLabelInfo { - const addedInfo = this.additionalPost?.thread?.labelInfo - return { - postLabels: (this.labels || []).concat(addedInfo?.postLabels || []), - accountLabels: filterAccountLabels(this.author.labels).concat( - addedInfo?.accountLabels || [], - ), - profileLabels: filterProfileLabels(this.author.labels).concat( - addedInfo?.profileLabels || [], - ), - isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false, - mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList, - isBlocking: - !!this.author.viewer?.blocking || addedInfo?.isBlocking || false, - isBlockedBy: - !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false, + get shouldFilter(): boolean { + if (this.additionalPost?.thread) { + const postMod = moderatePost( + this.additionalPost.thread.data.post, + this.rootStore.preferences.moderationOpts, + ) + return postMod.content.filter || false } - } - - get moderation(): PostModeration { - return getPostModeration(this.rootStore, this.labelInfo) + const profileMod = moderateProfile( + this.author, + this.rootStore.preferences.moderationOpts, + ) + return profileMod.account.filter || false } get numUnreadInGroup(): number { @@ -259,6 +241,12 @@ export class NotificationsFeedModel { loadMoreError = '' hasMore = true loadMoreCursor?: string + + /** + * The last time notifications were seen. Refers to either the + * user's machine clock or the value of the `indexedAt` property on their + * latest notification, whichever was greater at the time of viewing. + */ lastSync?: Date // used to linearize async modifications to state @@ -345,9 +333,6 @@ export class NotificationsFeedModel { limit: PAGE_SIZE, }) await this._replaceAll(res) - runInAction(() => { - this.lastSync = new Date() - }) this._setQueued(undefined) this._countUnread() this._xIdle() @@ -503,7 +488,9 @@ export class NotificationsFeedModel { const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({ uris: [addedUri], }) - notif.setAdditionalData(postsRes.data.posts[0]) + const post = postsRes.data.posts[0] + notif.setAdditionalData(post) + this.rootStore.posts.set(post.uri, post) } const filtered = this._filterNotifications([notif]) return filtered[0] @@ -539,9 +526,17 @@ export class NotificationsFeedModel { // = async _replaceAll(res: ListNotifications.Response) { - if (res.data.notifications[0]) { - this.mostRecentNotificationUri = res.data.notifications[0].uri + const latest = res.data.notifications[0] + + if (latest) { + const now = new Date() + const lastIndexed = new Date(latest.indexedAt) + const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed + + this.mostRecentNotificationUri = latest.uri + this.lastSync = nowOrLastIndexed } + return this._appendAll(res, true) } @@ -563,8 +558,7 @@ export class NotificationsFeedModel { ): NotificationsFeedItemModel[] { return items .filter(item => { - const hideByLabel = - item.moderation.list.behavior === ModerationBehaviorCode.Hide + const hideByLabel = item.shouldFilter let mutedThread = !!( item.reasonSubjectRootUri && this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) @@ -588,7 +582,7 @@ export class NotificationsFeedModel { for (const item of items) { const itemModel = new NotificationsFeedItemModel( this.rootStore, - `item-${_idCounter++}`, + `notification-${item.uri}`, item, ) const uri = itemModel.additionalDataUri @@ -611,6 +605,7 @@ export class NotificationsFeedModel { ), ) for (const post of postsChunks.flat()) { + this.rootStore.posts.set(post.uri, post) const models = addedPostMap.get(post.uri) if (models?.length) { for (const model of models) { diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts index 47039c72a..ae4f29105 100644 --- a/src/state/models/feeds/post.ts +++ b/src/state/models/feeds/post.ts @@ -3,21 +3,13 @@ import { AppBskyFeedPost as FeedPost, AppBskyFeedDefs, RichText, + moderatePost, + PostModeration, } from '@atproto/api' import {RootStoreModel} from '../root-store' import {updateDataOptimistically} from 'lib/async/revertible' -import {PostLabelInfo, PostModeration} from 'lib/labeling/types' -import { - getEmbedLabels, - getEmbedMuted, - getEmbedMutedByList, - getEmbedBlocking, - getEmbedBlockedBy, - filterAccountLabels, - filterProfileLabels, - getPostModeration, -} from 'lib/labeling/helpers' import {track} from 'lib/analytics/analytics' +import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed' type FeedViewPost = AppBskyFeedDefs.FeedViewPost type ReasonRepost = AppBskyFeedDefs.ReasonRepost @@ -36,14 +28,15 @@ export class PostsFeedItemModel { constructor( public rootStore: RootStoreModel, - reactKey: string, + _reactKey: string, v: FeedViewPost, ) { - this._reactKey = reactKey + this._reactKey = _reactKey this.post = v.post if (FeedPost.isRecord(this.post.record)) { const valid = FeedPost.validateRecord(this.post.record) if (valid.success) { + hackAddDeletedEmbed(this.post) this.postRecord = this.post.record this.richText = new RichText(this.postRecord, {cleanNewlines: true}) } else { @@ -86,33 +79,8 @@ export class PostsFeedItemModel { 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) + return moderatePost(this.post, this.rootStore.preferences.moderationOpts) } copy(v: FeedViewPost) { diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts index 239bc5b6a..16e4eef15 100644 --- a/src/state/models/feeds/posts-slice.ts +++ b/src/state/models/feeds/posts-slice.ts @@ -1,11 +1,8 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {FeedViewPostsSlice} from 'lib/api/feed-manip' -import {mergePostModerations} from 'lib/labeling/helpers' import {PostsFeedItemModel} from './post' -let _idCounter = 0 - export class PostsFeedSliceModel { // ui state _reactKey: string = '' @@ -13,15 +10,15 @@ export class PostsFeedSliceModel { // data items: PostsFeedItemModel[] = [] - constructor( - public rootStore: RootStoreModel, - reactKey: string, - slice: FeedViewPostsSlice, - ) { - this._reactKey = reactKey - for (const item of slice.items) { + constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { + this._reactKey = slice._reactKey + for (let i = 0; i < slice.items.length; i++) { this.items.push( - new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item), + new PostsFeedItemModel( + rootStore, + `${this._reactKey} - ${i}`, + slice.items[i], + ), ) } makeAutoObservable(this, {rootStore: false}) @@ -55,7 +52,20 @@ export class PostsFeedSliceModel { } get moderation() { - return mergePostModerations(this.items.map(item => item.moderation)) + // prefer the most stringent item + const topItem = this.items.find(item => item.moderation.content.filter) + if (topItem) { + return topItem.moderation + } + // otherwise just use the first one + return this.items[0].moderation + } + + shouldFilter(ignoreFilterForDid: string | undefined): boolean { + const mods = this.items + .filter(item => item.post.author.did !== ignoreFilterForDid) + .map(item => item.moderation) + return !!mods.find(mod => mod.content.filter) } containsUri(uri: string) { diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 4e6633d38..6facc27ad 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -8,12 +8,11 @@ import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' -import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' +import {FeedTuner} from 'lib/api/feed-manip' import {PostsFeedSliceModel} from './posts-slice' import {track} from 'lib/analytics/analytics' const PAGE_SIZE = 30 -let _idCounter = 0 type QueryParams = | GetTimeline.QueryParams @@ -75,24 +74,6 @@ export class PostsFeedModel { 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 - const allow = - !item.postRecord?.reply || // not a reply - isRepost // but allow if it's a repost - return allow - }) - } else { - return this.slices - } - } - setHasNewLatest(v: boolean) { this.hasNewLatest = v } @@ -282,31 +263,26 @@ export class PostsFeedModel { return } const res = await this._getFeed({limit: 1}) - this.setHasNewLatest(res.data.feed[0]?.post.uri !== this.pollCursor) + if (res.data.feed[0]) { + const slices = this.tuner.tune(res.data.feed, this.feedTuners) + if (slices[0]) { + const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) + if (sliceModel.moderation.content.filter) { + return + } + this.setHasNewLatest(sliceModel.uri !== this.pollCursor) + } + } } /** - * Fetches the given post and adds it to the top - * Used by the composer to add their new posts + * Updates the UI after the user has created a post */ - async addPostToTop(uri: string) { + onPostCreated() { if (!this.slices.length) { return this.refresh() - } - try { - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: [uri], - }) - const toPrepend = new PostsFeedSliceModel( - this.rootStore, - uri, - new FeedViewPostsSlice(res.data.posts.map(post => ({post}))), - ) - runInAction(() => { - this.slices = [toPrepend].concat(this.slices) - }) - } catch (e) { - this.rootStore.log.error('Failed to load post to prepend', {e}) + } else { + this.setHasNewLatest(true) } } @@ -374,16 +350,15 @@ export class PostsFeedModel { this.rootStore.me.follows.hydrateProfiles( res.data.feed.map(item => item.post.author), ) + for (const item of res.data.feed) { + this.rootStore.posts.fromFeedItem(item) + } 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, - ) + const sliceModel = new PostsFeedSliceModel(this.rootStore, slice) toAppend.push(sliceModel) } runInAction(() => { @@ -405,6 +380,7 @@ export class PostsFeedModel { res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, ) { for (const item of res.data.feed) { + this.rootStore.posts.fromFeedItem(item) const existingSlice = this.slices.find(slice => slice.containsUri(item.post.uri), ) diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 59d79f056..186e61cf6 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -52,6 +52,8 @@ export class MeModel { this.mainFeed.clear() this.notifications.clear() this.follows.clear() + this.rootStore.profiles.cache.clear() + this.rootStore.posts.cache.clear() this.did = '' this.handle = '' this.displayName = '' @@ -104,7 +106,6 @@ export class MeModel { this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession}) if (sess.hasSession) { this.did = sess.currentSession?.did || '' - this.handle = sess.currentSession?.handle || '' await this.fetchProfile() this.mainFeed.clear() /* dont await */ this.mainFeed.setup().catch(e => { @@ -144,6 +145,7 @@ export class MeModel { this.displayName = profile.data.displayName || '' this.description = profile.data.description || '' this.avatar = profile.data.avatar || '' + this.handle = profile.data.handle || '' this.followsCount = profile.data.followsCount this.followersCount = profile.data.followersCount } else { diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index e524c49de..dd5b36170 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -120,8 +120,8 @@ export class ImageModel implements Omit<RNImage, 'size'> { } } - async setAltText(altText: string) { - this.altText = altText + setAltText(altText: string) { + this.altText = altText.trim() } // Only compress prior to upload diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index d76ea07c9..6ced8090a 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -12,7 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards' import {LogModel} from './log' import {SessionModel} from './session' import {ShellUiModel} from './ui/shell' +import {HandleResolutionsCache} from './cache/handle-resolutions' import {ProfilesCache} from './cache/profiles-view' +import {PostsCache} from './cache/posts' import {LinkMetasCache} from './cache/link-metas' import {NotificationsFeedItemModel} from './feeds/notifications' import {MeModel} from './me' @@ -45,7 +47,9 @@ export class RootStoreModel { preferences = new PreferencesModel(this) me = new MeModel(this) invitedUsers = new InvitedUsers(this) + handleResolutions = new HandleResolutionsCache() profiles = new ProfilesCache(this) + posts = new PostsCache(this) linkMetas = new LinkMetasCache(this) imageSizes = new ImageSizesCache() mutedThreads = new MutedThreads() diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index e1c0b1f71..23668a3dc 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -1,9 +1,14 @@ import {makeAutoObservable, runInAction} from 'mobx' +import {LabelPreference as APILabelPreference} from '@atproto/api' import AwaitLock from 'await-lock' import isEqual from 'lodash.isequal' import {isObj, hasProp} from 'lib/type-guards' import {RootStoreModel} from '../root-store' -import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api' +import { + ComAtprotoLabelDefs, + AppBskyActorDefs, + ModerationOpts, +} from '@atproto/api' import {LabelValGroup} from 'lib/labeling/types' import {getLabelValueGroup} from 'lib/labeling/helpers' import { @@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS, deviceLocales} from 'platform/detection' import {LANGUAGES} from '../../../locale/languages' -export type LabelPreference = 'show' | 'warn' | 'hide' +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +export type LabelPreference = APILabelPreference | 'show' const LABEL_GROUPS = [ 'nsfw', 'nudity', @@ -408,6 +414,44 @@ export class PreferencesModel { return res } + get moderationOpts(): ModerationOpts { + return { + userDid: this.rootStore.session.currentSession?.did || '', + adultContentEnabled: this.adultContentEnabled, + labels: { + // TEMP translate old settings until this UI can be migrated -prf + porn: tempfixLabelPref(this.contentLabels.nsfw), + sexual: tempfixLabelPref(this.contentLabels.suggestive), + nudity: tempfixLabelPref(this.contentLabels.nudity), + nsfl: tempfixLabelPref(this.contentLabels.gore), + corpse: tempfixLabelPref(this.contentLabels.gore), + gore: tempfixLabelPref(this.contentLabels.gore), + torture: tempfixLabelPref(this.contentLabels.gore), + 'self-harm': tempfixLabelPref(this.contentLabels.gore), + 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), + 'intolerant-sexual-orientation': tempfixLabelPref( + this.contentLabels.hate, + ), + 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), + intolerant: tempfixLabelPref(this.contentLabels.hate), + 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), + spam: tempfixLabelPref(this.contentLabels.spam), + impersonation: tempfixLabelPref(this.contentLabels.impersonation), + scam: 'warn', + }, + labelers: [ + { + labeler: { + did: '', + displayName: 'Bluesky Social', + }, + labels: {}, + }, + ], + } + } + async setSavedFeeds(saved: string[], pinned: string[]) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds @@ -485,3 +529,11 @@ export class PreferencesModel { this.requireAltTextEnabled = !this.requireAltTextEnabled } } + +// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf +function tempfixLabelPref(pref: LabelPreference): APILabelPreference { + if (pref === 'show') { + return 'ignore' + } + return pref +} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index a0249d768..9dae09ec5 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -6,8 +6,9 @@ import {ActorFeedsModel} from '../lists/actor-feeds' import {ListsListModel} from '../lists/lists-list' export enum Sections { - Posts = 'Posts', + PostsNoReplies = 'Posts', PostsWithReplies = 'Posts & replies', + PostsWithMedia = 'Media', CustomAlgorithms = 'Feeds', Lists = 'Lists', } @@ -46,6 +47,7 @@ export class ProfileUiModel { this.feed = new PostsFeedModel(rootStore, 'author', { actor: params.user, limit: 10, + filter: 'posts_no_replies', }) this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) this.lists = new ListsListModel(rootStore, params.user) @@ -53,8 +55,9 @@ export class ProfileUiModel { get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { if ( - this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsNoReplies || + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.PostsWithMedia ) { return this.feed } else if (this.selectedView === Sections.Lists) { @@ -76,7 +79,11 @@ export class ProfileUiModel { } get selectorItems() { - const items = [Sections.Posts, Sections.PostsWithReplies] + const items = [ + Sections.PostsNoReplies, + Sections.PostsWithReplies, + Sections.PostsWithMedia, + ] if (this.algos.hasLoaded && !this.algos.isEmpty) { items.push(Sections.CustomAlgorithms) } @@ -90,7 +97,7 @@ export class ProfileUiModel { // If, for whatever reason, the selected view index is not available, default back to posts // This can happen when the user was focused on a view but performed an action that caused // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y) - return this.selectorItems[this.selectedViewIndex] || Sections.Posts + return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies } get uiItems() { @@ -107,26 +114,25 @@ export class ProfileUiModel { }, ]) } else { - // not loading, no error, show content if ( - this.selectedView === Sections.Posts || + this.selectedView === Sections.PostsNoReplies || this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.CustomAlgorithms + this.selectedView === Sections.PostsWithMedia ) { if (this.feed.hasContent) { - 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() - } + arr = this.feed.slices.slice() if (!this.feed.hasMore) { arr = arr.concat([ProfileUiModel.END_ITEM]) } } else if (this.feed.isEmpty) { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } + } else if (this.selectedView === Sections.CustomAlgorithms) { + if (this.algos.hasContent) { + arr = this.algos.feeds + } else if (this.algos.isEmpty) { + arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) + } } else if (this.selectedView === Sections.Lists) { if (this.lists.hasContent) { arr = this.lists.lists @@ -143,8 +149,9 @@ export class ProfileUiModel { get showLoadingMoreFooter() { if ( - this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsNoReplies || + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.PostsWithMedia ) { return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading } else if (this.selectedView === Sections.Lists) { @@ -157,7 +164,27 @@ export class ProfileUiModel { // = setSelectedViewIndex(index: number) { + // ViewSelector fires onSelectView on mount + if (index === this.selectedViewIndex) return + this.selectedViewIndex = index + + let filter = 'posts_no_replies' + if (this.selectedView === Sections.PostsWithReplies) { + filter = 'posts_with_replies' + } else if (this.selectedView === Sections.PostsWithMedia) { + filter = 'posts_with_media' + } + + this.feed = new PostsFeedModel(this.rootStore, 'author', { + actor: this.params.user, + limit: 10, + filter, + }) + + if (this.currentView instanceof PostsFeedModel) { + this.feed.setup() + } } async setup() { diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index e33a34acf..92d028c79 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,4 @@ -import {AppBskyEmbedRecord} from '@atproto/api' +import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' @@ -42,16 +42,21 @@ export interface ServerInputModal { onSelect: (url: string) => void } -export interface ReportPostModal { - name: 'report-post' - postUri: string - postCid: string +export interface ModerationDetailsModal { + name: 'moderation-details' + context: 'account' | 'content' + moderation: ModerationUI } -export interface ReportAccountModal { - name: 'report-account' - did: string -} +export type ReportModal = { + name: 'report' +} & ( + | { + uri: string + cid: string + } + | {did: string} +) export interface CreateOrEditMuteListModal { name: 'create-or-edit-mute-list' @@ -94,6 +99,13 @@ export interface RepostModal { isReposted: boolean } +export interface SelfLabelModal { + name: 'self-label' + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +} + export interface ChangeHandleModal { name: 'change-handle' onChanged: () => void @@ -146,8 +158,8 @@ export type Modal = | PreferencesHomeFeed // Moderation - | ReportAccountModal - | ReportPostModal + | ModerationDetailsModal + | ReportModal | CreateOrEditMuteListModal | ListAddRemoveUserModal @@ -157,6 +169,7 @@ export type Modal = | EditImageModal | ServerInputModal | RepostModal + | SelfLabelModal // Bluesky access | WaitlistModal |