about summary refs log tree commit diff
path: root/src/state/models
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models')
-rw-r--r--src/state/models/cache/handle-resolutions.ts5
-rw-r--r--src/state/models/cache/posts.ts70
-rw-r--r--src/state/models/cache/profiles-view.ts4
-rw-r--r--src/state/models/content/list.ts2
-rw-r--r--src/state/models/content/post-thread-item.ts8
-rw-r--r--src/state/models/content/post-thread.ts101
-rw-r--r--src/state/models/content/profile.ts58
-rw-r--r--src/state/models/discovery/foafs.ts29
-rw-r--r--src/state/models/discovery/suggested-actors.ts22
-rw-r--r--src/state/models/feeds/notifications.ts77
-rw-r--r--src/state/models/feeds/post.ts46
-rw-r--r--src/state/models/feeds/posts-slice.ts34
-rw-r--r--src/state/models/feeds/posts.ts64
-rw-r--r--src/state/models/me.ts4
-rw-r--r--src/state/models/media/image.ts4
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/state/models/ui/preferences.ts56
-rw-r--r--src/state/models/ui/profile.ts61
-rw-r--r--src/state/models/ui/shell.ts35
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