about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/lib/api/feed-manip.ts9
-rw-r--r--src/lib/hooks/usePermissions.ts11
-rw-r--r--src/lib/labeling/helpers.ts436
-rw-r--r--src/lib/labeling/types.ts53
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/feeds/posts.ts13
-rw-r--r--src/state/models/ui/preferences.ts234
-rw-r--r--src/state/models/ui/shell.ts5
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx43
-rw-r--r--src/view/com/composer/text-input/web/LinkDecorator.ts106
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx22
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx5
-rw-r--r--src/view/com/post/Post.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx1
-rw-r--r--src/view/com/util/Link.tsx16
-rw-r--r--src/view/com/util/ViewSelector.tsx1
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx (renamed from src/view/com/modals/PreferencesHomeFeed.tsx)52
-rw-r--r--src/view/screens/ProfileList.tsx2
-rw-r--r--src/view/screens/Search.web.tsx20
-rw-r--r--src/view/screens/Settings.tsx8
-rw-r--r--src/view/shell/desktop/LeftNav.tsx7
25 files changed, 305 insertions, 755 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index d45376ef1..2422491e2 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -67,6 +67,7 @@ import {getRoutingInstrumentation} from 'lib/sentry'
 import {bskyTitle} from 'lib/strings/headings'
 import {JSX} from 'react/jsx-runtime'
 import {timeout} from 'lib/async/timeout'
+import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -219,6 +220,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={SavedFeeds}
         options={{title: title('Edit My Feeds')}}
       />
+      <Stack.Screen
+        name="PreferencesHomeFeed"
+        component={PreferencesHomeFeed}
+        options={{title: title('Home Feed Preferences')}}
+      />
     </>
   )
 }
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 472289b40..60b0f2641 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -105,6 +105,7 @@ export class FeedTuner {
   tune(
     feed: FeedViewPost[],
     tunerFns: FeedTunerFn[] = [],
+    {dryRun}: {dryRun: boolean} = {dryRun: false},
   ): FeedViewPostsSlice[] {
     let slices: FeedViewPostsSlice[] = []
 
@@ -156,9 +157,11 @@ export class FeedTuner {
       }
     }
 
-    for (const slice of slices) {
-      for (const item of slice.items) {
-        this.seenUris.add(item.post.uri)
+    if (!dryRun) {
+      for (const slice of slices) {
+        for (const item of slice.items) {
+          this.seenUris.add(item.post.uri)
+        }
       }
     }
 
diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts
index 9cb4a80dd..138f3eaca 100644
--- a/src/lib/hooks/usePermissions.ts
+++ b/src/lib/hooks/usePermissions.ts
@@ -29,9 +29,14 @@ export function usePhotoLibraryPermission() {
 
     if (res?.granted) {
       return true
-    } else if (!res || res?.status === 'undetermined' || res?.canAskAgain) {
-      const updatedRes = await requestPermission()
-      return updatedRes?.granted
+    } else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
+      const {canAskAgain, granted, status} = await requestPermission()
+
+      if (!canAskAgain && status === 'undetermined') {
+        openPermissionAlert('photo library')
+      }
+
+      return granted
     } else {
       openPermissionAlert('photo library')
       return false
diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts
deleted file mode 100644
index 447b0a99a..000000000
--- a/src/lib/labeling/helpers.ts
+++ /dev/null
@@ -1,436 +0,0 @@
-import {
-  AppBskyActorDefs,
-  AppBskyGraphDefs,
-  AppBskyEmbedRecordWithMedia,
-  AppBskyEmbedRecord,
-  AppBskyEmbedImages,
-  AppBskyEmbedExternal,
-} from '@atproto/api'
-import {
-  CONFIGURABLE_LABEL_GROUPS,
-  ILLEGAL_LABEL_GROUP,
-  ALWAYS_FILTER_LABEL_GROUP,
-  ALWAYS_WARN_LABEL_GROUP,
-  UNKNOWN_LABEL_GROUP,
-} from './const'
-import {
-  Label,
-  LabelValGroup,
-  ModerationBehaviorCode,
-  ModerationBehavior,
-  PostModeration,
-  ProfileModeration,
-  PostLabelInfo,
-  ProfileLabelInfo,
-} from './types'
-import {RootStoreModel} from 'state/index'
-
-type Embed =
-  | AppBskyEmbedRecord.View
-  | AppBskyEmbedImages.View
-  | AppBskyEmbedExternal.View
-  | AppBskyEmbedRecordWithMedia.View
-  | {$type: string; [k: string]: unknown}
-
-export function getLabelValueGroup(labelVal: string): LabelValGroup {
-  let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
-  for (id in CONFIGURABLE_LABEL_GROUPS) {
-    if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) {
-      return ILLEGAL_LABEL_GROUP
-    }
-    if (ALWAYS_FILTER_LABEL_GROUP.values.includes(labelVal)) {
-      return ALWAYS_FILTER_LABEL_GROUP
-    }
-    if (ALWAYS_WARN_LABEL_GROUP.values.includes(labelVal)) {
-      return ALWAYS_WARN_LABEL_GROUP
-    }
-    if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) {
-      return CONFIGURABLE_LABEL_GROUPS[id]
-    }
-  }
-  return UNKNOWN_LABEL_GROUP
-}
-
-export function getPostModeration(
-  store: RootStoreModel,
-  postInfo: PostLabelInfo,
-): PostModeration {
-  const accountPref = store.preferences.getLabelPreference(
-    postInfo.accountLabels,
-  )
-  const profilePref = store.preferences.getLabelPreference(
-    postInfo.profileLabels,
-  )
-  const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
-
-  // avatar
-  let avatar = {
-    warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
-    blur:
-      postInfo.isBlocking ||
-      accountPref.pref === 'hide' ||
-      accountPref.pref === 'warn' ||
-      profilePref.pref === 'hide' ||
-      profilePref.pref === 'warn',
-  }
-
-  // hide no-override cases
-  if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
-    return hidePostNoOverride(accountPref.desc.warning)
-  }
-  if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
-    return hidePostNoOverride(profilePref.desc.warning)
-  }
-  if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
-    return hidePostNoOverride(postPref.desc.warning)
-  }
-
-  // hide cases
-  if (postInfo.isBlocking) {
-    return {
-      avatar,
-      list: hide('Post from an account you blocked.'),
-      thread: hide('Post from an account you blocked.'),
-      view: warn('Post from an account you blocked.'),
-    }
-  }
-  if (postInfo.isBlockedBy) {
-    return {
-      avatar,
-      list: hide('Post from an account that has blocked you.'),
-      thread: hide('Post from an account that has blocked you.'),
-      view: warn('Post from an account that has blocked you.'),
-    }
-  }
-  if (accountPref.pref === 'hide') {
-    return {
-      avatar,
-      list: hide(accountPref.desc.warning),
-      thread: hide(accountPref.desc.warning),
-      view: warn(accountPref.desc.warning),
-    }
-  }
-  if (profilePref.pref === 'hide') {
-    return {
-      avatar,
-      list: hide(profilePref.desc.warning),
-      thread: hide(profilePref.desc.warning),
-      view: warn(profilePref.desc.warning),
-    }
-  }
-  if (postPref.pref === 'hide') {
-    return {
-      avatar,
-      list: hide(postPref.desc.warning),
-      thread: hide(postPref.desc.warning),
-      view: warn(postPref.desc.warning),
-    }
-  }
-
-  // muting
-  if (postInfo.isMuted) {
-    let msg = 'Post from an account you muted.'
-    if (postInfo.mutedByList) {
-      msg = `Muted by ${postInfo.mutedByList.name}`
-    }
-    return {
-      avatar,
-      list: isMute(hide(msg)),
-      thread: isMute(warn(msg)),
-      view: isMute(warn(msg)),
-    }
-  }
-
-  // warning cases
-  if (postPref.pref === 'warn') {
-    if (postPref.desc.isAdultImagery) {
-      return {
-        avatar,
-        list: warnImages(postPref.desc.warning),
-        thread: warnImages(postPref.desc.warning),
-        view: warnImages(postPref.desc.warning),
-      }
-    }
-    return {
-      avatar,
-      list: warnContent(postPref.desc.warning),
-      thread: warnContent(postPref.desc.warning),
-      view: warnContent(postPref.desc.warning),
-    }
-  }
-  if (accountPref.pref === 'warn') {
-    return {
-      avatar,
-      list: warnContent(accountPref.desc.warning),
-      thread: warnContent(accountPref.desc.warning),
-      view: warnContent(accountPref.desc.warning),
-    }
-  }
-
-  return {
-    avatar,
-    list: show(),
-    thread: show(),
-    view: show(),
-  }
-}
-
-export function mergePostModerations(
-  moderations: PostModeration[],
-): PostModeration {
-  const merged: PostModeration = {
-    avatar: {warn: false, blur: false},
-    list: show(),
-    thread: show(),
-    view: show(),
-  }
-  for (const mod of moderations) {
-    if (mod.list.behavior === ModerationBehaviorCode.Hide) {
-      merged.list = mod.list
-    }
-    if (mod.thread.behavior === ModerationBehaviorCode.Hide) {
-      merged.thread = mod.thread
-    }
-    if (mod.view.behavior === ModerationBehaviorCode.Hide) {
-      merged.view = mod.view
-    }
-  }
-  return merged
-}
-
-export function getProfileModeration(
-  store: RootStoreModel,
-  profileInfo: ProfileLabelInfo,
-): ProfileModeration {
-  const accountPref = store.preferences.getLabelPreference(
-    profileInfo.accountLabels,
-  )
-  const profilePref = store.preferences.getLabelPreference(
-    profileInfo.profileLabels,
-  )
-
-  // avatar
-  let avatar = {
-    warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
-    blur:
-      profileInfo.isBlocking ||
-      accountPref.pref === 'hide' ||
-      accountPref.pref === 'warn' ||
-      profilePref.pref === 'hide' ||
-      profilePref.pref === 'warn',
-  }
-
-  // hide no-override cases
-  if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
-    return hideProfileNoOverride(accountPref.desc.warning)
-  }
-  if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
-    return hideProfileNoOverride(profilePref.desc.warning)
-  }
-
-  // hide cases
-  if (accountPref.pref === 'hide') {
-    return {
-      avatar,
-      list: hide(accountPref.desc.warning),
-      view: hide(accountPref.desc.warning),
-    }
-  }
-  if (profilePref.pref === 'hide') {
-    return {
-      avatar,
-      list: hide(profilePref.desc.warning),
-      view: hide(profilePref.desc.warning),
-    }
-  }
-
-  // warn cases
-  if (accountPref.pref === 'warn') {
-    return {
-      avatar,
-      list:
-        profileInfo.isBlocking || profileInfo.isBlockedBy
-          ? hide('Blocked account')
-          : warn(accountPref.desc.warning),
-      view: warn(accountPref.desc.warning),
-    }
-  }
-  // we don't warn for this
-  // if (profilePref.pref === 'warn') {
-  //   return {
-  //     avatar,
-  //     list: warn(profilePref.desc.warning),
-  //     view: warn(profilePref.desc.warning),
-  //   }
-  // }
-
-  return {
-    avatar,
-    list: profileInfo.isBlocking ? hide('Blocked account') : show(),
-    view: show(),
-  }
-}
-
-export function getProfileViewBasicLabelInfo(
-  profile: AppBskyActorDefs.ProfileViewBasic,
-): ProfileLabelInfo {
-  return {
-    accountLabels: filterAccountLabels(profile.labels),
-    profileLabels: filterProfileLabels(profile.labels),
-    isMuted: profile.viewer?.muted || false,
-    isBlocking: !!profile.viewer?.blocking || false,
-    isBlockedBy: !!profile.viewer?.blockedBy || false,
-  }
-}
-
-export function getEmbedLabels(embed?: Embed): Label[] {
-  if (!embed) {
-    return []
-  }
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record)
-  ) {
-    return embed.record.labels || []
-  }
-  return []
-}
-
-export function getEmbedMuted(embed?: Embed): boolean {
-  if (!embed) {
-    return false
-  }
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record)
-  ) {
-    return !!embed.record.author.viewer?.muted
-  }
-  return false
-}
-
-export function getEmbedMutedByList(
-  embed?: Embed,
-): AppBskyGraphDefs.ListViewBasic | undefined {
-  if (!embed) {
-    return undefined
-  }
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record)
-  ) {
-    return embed.record.author.viewer?.mutedByList
-  }
-  return undefined
-}
-
-export function getEmbedBlocking(embed?: Embed): boolean {
-  if (!embed) {
-    return false
-  }
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record)
-  ) {
-    return !!embed.record.author.viewer?.blocking
-  }
-  return false
-}
-
-export function getEmbedBlockedBy(embed?: Embed): boolean {
-  if (!embed) {
-    return false
-  }
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record)
-  ) {
-    return !!embed.record.author.viewer?.blockedBy
-  }
-  return false
-}
-
-export function filterAccountLabels(labels?: Label[]): Label[] {
-  if (!labels) {
-    return []
-  }
-  return labels.filter(
-    label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
-  )
-}
-
-export function filterProfileLabels(labels?: Label[]): Label[] {
-  if (!labels) {
-    return []
-  }
-  return labels.filter(label =>
-    label.uri.endsWith('/app.bsky.actor.profile/self'),
-  )
-}
-
-// internal methods
-// =
-
-function show() {
-  return {
-    behavior: ModerationBehaviorCode.Show,
-  }
-}
-
-function hidePostNoOverride(reason: string) {
-  return {
-    avatar: {warn: true, blur: true},
-    list: hideNoOverride(reason),
-    thread: hideNoOverride(reason),
-    view: hideNoOverride(reason),
-  }
-}
-
-function hideProfileNoOverride(reason: string) {
-  return {
-    avatar: {warn: true, blur: true},
-    list: hideNoOverride(reason),
-    view: hideNoOverride(reason),
-  }
-}
-
-function hideNoOverride(reason: string) {
-  return {
-    behavior: ModerationBehaviorCode.Hide,
-    reason,
-    noOverride: true,
-  }
-}
-
-function hide(reason: string) {
-  return {
-    behavior: ModerationBehaviorCode.Hide,
-    reason,
-  }
-}
-
-function warn(reason: string) {
-  return {
-    behavior: ModerationBehaviorCode.Warn,
-    reason,
-  }
-}
-
-function warnContent(reason: string) {
-  return {
-    behavior: ModerationBehaviorCode.WarnContent,
-    reason,
-  }
-}
-
-function isMute(behavior: ModerationBehavior): ModerationBehavior {
-  behavior.isMute = true
-  return behavior
-}
-
-function warnImages(reason: string) {
-  return {
-    behavior: ModerationBehaviorCode.WarnImages,
-    reason,
-  }
-}
diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts
index 1ee058024..84d59be7f 100644
--- a/src/lib/labeling/types.ts
+++ b/src/lib/labeling/types.ts
@@ -1,4 +1,4 @@
-import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api'
+import {ComAtprotoLabelDefs} from '@atproto/api'
 import {LabelPreferencesModel} from 'state/models/ui/preferences'
 
 export type Label = ComAtprotoLabelDefs.Label
@@ -16,54 +16,3 @@ export interface LabelValGroup {
   warning: string
   values: string[]
 }
-
-export interface PostLabelInfo {
-  postLabels: Label[]
-  accountLabels: Label[]
-  profileLabels: Label[]
-  isMuted: boolean
-  mutedByList?: AppBskyGraphDefs.ListViewBasic
-  isBlocking: boolean
-  isBlockedBy: boolean
-}
-
-export interface ProfileLabelInfo {
-  accountLabels: Label[]
-  profileLabels: Label[]
-  isMuted: boolean
-  isBlocking: boolean
-  isBlockedBy: boolean
-}
-
-export enum ModerationBehaviorCode {
-  Show,
-  Hide,
-  Warn,
-  WarnContent,
-  WarnImages,
-}
-
-export interface ModerationBehavior {
-  behavior: ModerationBehaviorCode
-  isMute?: boolean
-  noOverride?: boolean
-  reason?: string
-}
-
-export interface AvatarModeration {
-  warn: boolean
-  blur: boolean
-}
-
-export interface PostModeration {
-  avatar: AvatarModeration
-  list: ModerationBehavior
-  thread: ModerationBehavior
-  view: ModerationBehavior
-}
-
-export interface ProfileModeration {
-  avatar: AvatarModeration
-  list: ModerationBehavior
-  view: ModerationBehavior
-}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 4eb5e29d2..7159bcb51 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -29,6 +29,7 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
+  PreferencesHomeFeed: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index 54faba22d..45a8fa572 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -23,6 +23,7 @@ export const router = new Router({
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
+  PreferencesHomeFeed: '/settings/home-feed',
   SavedFeeds: '/settings/saved-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 15145a203..c88249c8f 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -277,7 +277,9 @@ export class PostsFeedModel {
     }
     const res = await this._getFeed({limit: 1})
     if (res.data.feed[0]) {
-      const slices = this.tuner.tune(res.data.feed, this.feedTuners)
+      const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
+        dryRun: true,
+      })
       if (slices[0]) {
         const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
         if (sliceModel.moderation.content.filter) {
@@ -374,6 +376,15 @@ export class PostsFeedModel {
     const toAppend: PostsFeedSliceModel[] = []
     for (const slice of slices) {
       const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
+      const dupTest = (item: PostsFeedSliceModel) =>
+        item._reactKey === sliceModel._reactKey
+      // sanity check
+      // if a duplicate _reactKey passes through, the UI breaks hard
+      if (!replace) {
+        if (this.slices.find(dupTest) || toAppend.find(dupTest)) {
+          continue
+        }
+      }
       toAppend.push(sliceModel)
     }
     runInAction(() => {
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index e9ffe28c2..3b03cdca1 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -4,21 +4,9 @@ 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,
-  ModerationOpts,
-} from '@atproto/api'
-import {LabelValGroup} from 'lib/labeling/types'
-import {getLabelValueGroup} from 'lib/labeling/helpers'
-import {
-  UNKNOWN_LABEL_GROUP,
-  ILLEGAL_LABEL_GROUP,
-  ALWAYS_FILTER_LABEL_GROUP,
-  ALWAYS_WARN_LABEL_GROUP,
-} from 'lib/labeling/const'
+import {ModerationOpts} from '@atproto/api'
 import {DEFAULT_FEEDS} from 'lib/constants'
-import {isIOS, deviceLocales} from 'platform/detection'
+import {deviceLocales} from 'platform/detection'
 import {LANGUAGES} from '../../../locale/languages'
 
 // TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
@@ -32,7 +20,7 @@ const LABEL_GROUPS = [
   'spam',
   'impersonation',
 ]
-const VISIBILITY_VALUES = ['show', 'warn', 'hide']
+const VISIBILITY_VALUES = ['ignore', 'warn', 'hide']
 const DEFAULT_LANG_CODES = (deviceLocales || [])
   .concat(['en', 'ja', 'pt', 'de'])
   .slice(0, 6)
@@ -52,7 +40,7 @@ export class LabelPreferencesModel {
 }
 
 export class PreferencesModel {
-  adultContentEnabled = !isIOS
+  adultContentEnabled = false
   contentLanguages: string[] = deviceLocales || []
   postLanguage: string = deviceLocales[0] || 'en'
   postLanguageHistory: string[] = DEFAULT_LANG_CODES
@@ -189,43 +177,32 @@ export class PreferencesModel {
     await this.lock.acquireAsync()
     try {
       // fetch preferences
-      let hasSavedFeedsPref = false
-      const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+      const prefs = await this.rootStore.agent.getPreferences()
+
       runInAction(() => {
-        for (const pref of res.data.preferences) {
+        this.adultContentEnabled = prefs.adultContentEnabled
+        for (const label in prefs.contentLabels) {
           if (
-            AppBskyActorDefs.isAdultContentPref(pref) &&
-            AppBskyActorDefs.validateAdultContentPref(pref).success
-          ) {
-            this.adultContentEnabled = pref.enabled
-          } else if (
-            AppBskyActorDefs.isContentLabelPref(pref) &&
-            AppBskyActorDefs.validateAdultContentPref(pref).success
+            LABEL_GROUPS.includes(label) &&
+            VISIBILITY_VALUES.includes(prefs.contentLabels[label])
           ) {
-            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
-          ) {
-            if (!isEqual(this.savedFeeds, pref.saved)) {
-              this.savedFeeds = pref.saved
-            }
-            if (!isEqual(this.pinnedFeeds, pref.pinned)) {
-              this.pinnedFeeds = pref.pinned
-            }
-            hasSavedFeedsPref = true
+            this.contentLabels[label as keyof LabelPreferencesModel] =
+              prefs.contentLabels[label]
           }
         }
+        if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) {
+          this.savedFeeds = prefs.feeds.saved
+        }
+        if (
+          prefs.feeds.pinned &&
+          !isEqual(this.pinnedFeeds, prefs.feeds.pinned)
+        ) {
+          this.pinnedFeeds = prefs.feeds.pinned
+        }
       })
 
       // set defaults on missing items
-      if (!hasSavedFeedsPref) {
+      if (typeof prefs.feeds.saved === 'undefined') {
         const {saved, pinned} = await DEFAULT_FEEDS(
           this.rootStore.agent.service.toString(),
           (handle: string) =>
@@ -237,14 +214,7 @@ export class PreferencesModel {
           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,
-        })
+        await this.rootStore.agent.setSavedFeeds(saved, pinned)
       }
     } finally {
       this.lock.release()
@@ -254,35 +224,6 @@ export class PreferencesModel {
   }
 
   /**
-   * 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,
-    ) => AppBskyActorDefs.Preferences | false,
-  ) {
-    await this.lock.acquireAsync()
-    try {
-      const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
-      const newPrefs = cb(res.data.preferences)
-      if (newPrefs === false) {
-        return
-      }
-      await this.rootStore.agent.app.bsky.actor.putPreferences({
-        preferences: newPrefs,
-      })
-    } finally {
-      this.lock.release()
-    }
-  }
-
-  /**
    * This function resets the preferences to an empty array of no preferences.
    */
   async reset() {
@@ -381,84 +322,12 @@ export class PreferencesModel {
     value: LabelPreference,
   ) {
     this.contentLabels[key] = value
-
-    await this.update((prefs: AppBskyActorDefs.Preferences) => {
-      const existing = prefs.find(
-        pref =>
-          AppBskyActorDefs.isContentLabelPref(pref) &&
-          AppBskyActorDefs.validateAdultContentPref(pref).success &&
-          pref.label === key,
-      )
-      if (existing) {
-        existing.visibility = value
-      } else {
-        prefs.push({
-          $type: 'app.bsky.actor.defs#contentLabelPref',
-          label: key,
-          visibility: value,
-        })
-      }
-      return prefs
-    })
+    await this.rootStore.agent.setContentLabelPref(key, value)
   }
 
   async setAdultContentEnabled(v: boolean) {
     this.adultContentEnabled = v
-    await this.update((prefs: AppBskyActorDefs.Preferences) => {
-      const existing = prefs.find(
-        pref =>
-          AppBskyActorDefs.isAdultContentPref(pref) &&
-          AppBskyActorDefs.validateAdultContentPref(pref).success,
-      )
-      if (existing) {
-        existing.enabled = v
-      } else {
-        prefs.push({
-          $type: 'app.bsky.actor.defs#adultContentPref',
-          enabled: v,
-        })
-      }
-      return prefs
-    })
-  }
-
-  getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): {
-    pref: LabelPreference
-    desc: LabelValGroup
-  } {
-    let res: {pref: LabelPreference; desc: LabelValGroup} = {
-      pref: 'show',
-      desc: UNKNOWN_LABEL_GROUP,
-    }
-    if (!labels?.length) {
-      return res
-    }
-    for (const label of labels) {
-      const group = getLabelValueGroup(label.val)
-      if (group.id === 'illegal') {
-        return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP}
-      } else if (group.id === 'always-filter') {
-        return {pref: 'hide', desc: ALWAYS_FILTER_LABEL_GROUP}
-      } else if (group.id === 'always-warn') {
-        res.pref = 'warn'
-        res.desc = ALWAYS_WARN_LABEL_GROUP
-        continue
-      } else if (group.id === 'unknown') {
-        continue
-      }
-      let pref = this.contentLabels[group.id]
-      if (pref === 'hide') {
-        res.pref = 'hide'
-        res.desc = group
-      } else if (pref === 'warn' && res.pref === 'show') {
-        res.pref = 'warn'
-        res.desc = group
-      }
-    }
-    if (res.desc.isAdultImagery && !this.adultContentEnabled) {
-      res.pref = 'hide'
-    }
-    return res
+    await this.rootStore.agent.setAdultContentEnabled(v)
   }
 
   get moderationOpts(): ModerationOpts {
@@ -499,31 +368,20 @@ export class PreferencesModel {
     }
   }
 
-  async setSavedFeeds(saved: string[], pinned: string[]) {
+  async _optimisticUpdateSavedFeeds(
+    saved: string[],
+    pinned: string[],
+    cb: () => Promise<{saved: string[]; pinned: string[]}>,
+  ) {
     const oldSaved = this.savedFeeds
     const oldPinned = this.pinnedFeeds
     this.savedFeeds = saved
     this.pinnedFeeds = pinned
     try {
-      await this.update((prefs: AppBskyActorDefs.Preferences) => {
-        let feedsPref = prefs.find(
-          pref =>
-            AppBskyActorDefs.isSavedFeedsPref(pref) &&
-            AppBskyActorDefs.validateSavedFeedsPref(pref).success,
-        )
-        if (feedsPref) {
-          feedsPref.saved = saved
-          feedsPref.pinned = pinned
-        } else {
-          feedsPref = {
-            $type: 'app.bsky.actor.defs#savedFeedsPref',
-            saved,
-            pinned,
-          }
-        }
-        return prefs
-          .filter(pref => !AppBskyActorDefs.isSavedFeedsPref(pref))
-          .concat([feedsPref])
+      const res = await cb()
+      runInAction(() => {
+        this.savedFeeds = res.saved
+        this.pinnedFeeds = res.pinned
       })
     } catch (e) {
       runInAction(() => {
@@ -534,25 +392,41 @@ export class PreferencesModel {
     }
   }
 
+  async setSavedFeeds(saved: string[], pinned: string[]) {
+    return this._optimisticUpdateSavedFeeds(saved, pinned, () =>
+      this.rootStore.agent.setSavedFeeds(saved, pinned),
+    )
+  }
+
   async addSavedFeed(v: string) {
-    return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds)
+    return this._optimisticUpdateSavedFeeds(
+      [...this.savedFeeds, v],
+      this.pinnedFeeds,
+      () => this.rootStore.agent.addSavedFeed(v),
+    )
   }
 
   async removeSavedFeed(v: string) {
-    return this.setSavedFeeds(
+    return this._optimisticUpdateSavedFeeds(
       this.savedFeeds.filter(uri => uri !== v),
       this.pinnedFeeds.filter(uri => uri !== v),
+      () => this.rootStore.agent.removeSavedFeed(v),
     )
   }
 
   async addPinnedFeed(v: string) {
-    return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v])
+    return this._optimisticUpdateSavedFeeds(
+      this.savedFeeds,
+      [...this.pinnedFeeds, v],
+      () => this.rootStore.agent.addPinnedFeed(v),
+    )
   }
 
   async removePinnedFeed(v: string) {
-    return this.setSavedFeeds(
+    return this._optimisticUpdateSavedFeeds(
       this.savedFeeds,
       this.pinnedFeeds.filter(uri => uri !== v),
+      () => this.rootStore.agent.removePinnedFeed(v),
     )
   }
 
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index a64047f9f..33fdd5710 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -136,10 +136,6 @@ export interface PostLanguagesSettingsModal {
   name: 'post-languages-settings'
 }
 
-export interface PreferencesHomeFeed {
-  name: 'preferences-home-feed'
-}
-
 export type Modal =
   // Account
   | AddAppPasswordModal
@@ -152,7 +148,6 @@ export type Modal =
   | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
   | PostLanguagesSettingsModal
-  | PreferencesHomeFeed
 
   // Moderation
   | ModerationDetailsModal
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index dfe1e26a1..395263af8 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,12 +1,11 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {RichText} from '@atproto/api'
+import {RichText, AppBskyRichtextFacet} from '@atproto/api'
 import EventEmitter from 'eventemitter3'
 import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
 import {Document} from '@tiptap/extension-document'
 import History from '@tiptap/extension-history'
 import Hardbreak from '@tiptap/extension-hard-break'
-import {Link} from '@tiptap/extension-link'
 import {Mention} from '@tiptap/extension-mention'
 import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
@@ -17,6 +16,7 @@ import {createSuggestion} from './web/Autocomplete'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {isUriImage, blobToDataUri} from 'lib/media/util'
 import {Emoji} from './web/EmojiPicker.web'
+import {LinkDecorator} from './web/LinkDecorator'
 
 export interface TextInputRef {
   focus: () => void
@@ -74,11 +74,7 @@ export const TextInput = React.forwardRef(
       {
         extensions: [
           Document,
-          Link.configure({
-            protocols: ['http', 'https'],
-            autolink: true,
-            linkOnPaste: false,
-          }),
+          LinkDecorator,
           Mention.configure({
             HTMLAttributes: {
               class: 'mention',
@@ -128,9 +124,20 @@ export const TextInput = React.forwardRef(
           newRt.detectFacetsWithoutResolution()
           setRichText(newRt)
 
-          const newSuggestedLinks = new Set(editorJsonToLinks(json))
-          if (!isEqual(newSuggestedLinks, suggestedLinks)) {
-            onSuggestedLinksChanged(newSuggestedLinks)
+          const set: Set<string> = new Set()
+
+          if (newRt.facets) {
+            for (const facet of newRt.facets) {
+              for (const feature of facet.features) {
+                if (AppBskyRichtextFacet.isLink(feature)) {
+                  set.add(feature.uri)
+                }
+              }
+            }
+          }
+
+          if (!isEqual(set, suggestedLinks)) {
+            onSuggestedLinksChanged(set)
           }
         },
       },
@@ -237,22 +244,6 @@ function textToEditorJson(text: string): JSONContent {
   }
 }
 
-function editorJsonToLinks(json: JSONContent): string[] {
-  let links: string[] = []
-  if (json.content?.length) {
-    for (const node of json.content) {
-      links = links.concat(editorJsonToLinks(node))
-    }
-  }
-
-  const link = json.marks?.find(m => m.type === 'link')
-  if (link?.attrs?.href) {
-    links.push(link.attrs.href)
-  }
-
-  return links
-}
-
 const styles = StyleSheet.create({
   container: {
     flex: 1,
diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts
new file mode 100644
index 000000000..531e8d5a0
--- /dev/null
+++ b/src/view/com/composer/text-input/web/LinkDecorator.ts
@@ -0,0 +1,106 @@
+/**
+ * TipTap is a stateful rich-text editor, which is extremely useful
+ * when you _want_ it to be stateful formatting such as bold and italics.
+ *
+ * However we also use "stateless" behaviors, specifically for URLs
+ * where the text itself drives the formatting.
+ *
+ * This plugin uses a regex to detect URIs and then applies
+ * link decorations (a <span> with the "autolink") class. That avoids
+ * adding any stateful formatting to TipTap's document model.
+ *
+ * We then run the URI detection again when constructing the
+ * RichText object from TipTap's output and merge their features into
+ * the facet-set.
+ */
+
+import {Mark} from '@tiptap/core'
+import {Plugin, PluginKey} from '@tiptap/pm/state'
+import {findChildren} from '@tiptap/core'
+import {Node as ProsemirrorNode} from '@tiptap/pm/model'
+import {Decoration, DecorationSet} from '@tiptap/pm/view'
+import {isValidDomain} from 'lib/strings/url-helpers'
+
+export const LinkDecorator = Mark.create({
+  name: 'link-decorator',
+  priority: 1000,
+  keepOnSplit: false,
+  inclusive() {
+    return true
+  },
+  addProseMirrorPlugins() {
+    return [linkDecorator()]
+  },
+})
+
+function getDecorations(doc: ProsemirrorNode) {
+  const decorations: Decoration[] = []
+
+  findChildren(doc, node => node.type.name === 'paragraph').forEach(
+    paragraphNode => {
+      const textContent = paragraphNode.node.textContent
+
+      // links
+      iterateUris(textContent, (from, to) => {
+        decorations.push(
+          Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, {
+            class: 'autolink',
+          }),
+        )
+      })
+    },
+  )
+
+  return DecorationSet.create(doc, decorations)
+}
+
+function linkDecorator() {
+  const linkDecoratorPlugin: Plugin = new Plugin({
+    key: new PluginKey('link-decorator'),
+
+    state: {
+      init: (_, {doc}) => getDecorations(doc),
+      apply: (transaction, decorationSet) => {
+        if (transaction.docChanged) {
+          return getDecorations(transaction.doc)
+        }
+        return decorationSet.map(transaction.mapping, transaction.doc)
+      },
+    },
+
+    props: {
+      decorations(state) {
+        return linkDecoratorPlugin.getState(state)
+      },
+    },
+  })
+  return linkDecoratorPlugin
+}
+
+function iterateUris(str: string, cb: (from: number, to: number) => void) {
+  let match
+  const re =
+    /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
+  while ((match = re.exec(str))) {
+    let uri = match[2]
+    if (!uri.startsWith('http')) {
+      const domain = match.groups?.domain
+      if (!domain || !isValidDomain(domain)) {
+        continue
+      }
+      uri = `https://${uri}`
+    }
+    let from = str.indexOf(match[2], match.index)
+    let to = from + match[2].length + 1
+    // strip ending puncuation
+    if (/[.,;!?]$/.test(uri)) {
+      uri = uri.slice(0, -1)
+      to--
+    }
+    if (/[)]$/.test(uri) && !uri.includes('(')) {
+      uri = uri.slice(0, -1)
+      to--
+    }
+    cb(from, to)
+  }
+}
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 5215c9cb4..f39351feb 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -48,15 +48,17 @@ export const Component = observer(({}: {}) => {
       <ScrollView style={styles.scrollContainer}>
         <View style={s.mb10}>
           {isIOS ? (
-            <Text type="md" style={pal.textLight}>
-              Adult content can only be enabled via the Web at{' '}
-              <TextLink
-                style={pal.link}
-                href="https://bsky.app"
-                text="bsky.app"
-              />
-              .
-            </Text>
+            store.preferences.adultContentEnabled ? null : (
+              <Text type="md" style={pal.textLight}>
+                Adult content can only be enabled via the Web at{' '}
+                <TextLink
+                  style={pal.link}
+                  href="https://bsky.app"
+                  text="bsky.app"
+                />
+                .
+              </Text>
+            )
           ) : (
             <ToggleButton
               type="default-light"
@@ -188,7 +190,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) {
       />
       <SelectableBtn
         current={current}
-        value="show"
+        value="ignore"
         label="Show"
         right
         onChange={onChange}
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index dd45262be..4a5a7c504 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 import * as ModerationDetailsModal from './ModerationDetails'
 
 const DEFAULT_SNAPPOINTS = ['90%']
@@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'post-languages-settings') {
     snapPoints = PostLanguagesSettingsModal.snapPoints
     element = <PostLanguagesSettingsModal.Component />
-  } else if (activeModal?.name === 'preferences-home-feed') {
-    snapPoints = PreferencesHomeFeed.snapPoints
-    element = <PreferencesHomeFeed.Component />
   } else if (activeModal?.name === 'moderation-details') {
     snapPoints = ModerationDetailsModal.snapPoints
     element = <ModerationDetailsModal.Component {...activeModal} />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 3aeddeb6b..5cfdd6bb3 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -27,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as ModerationDetailsModal from './ModerationDetails'
-import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
@@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <AltTextImageModal.Component {...modal} />
   } else if (modal.name === 'edit-image') {
     element = <EditImageModal.Component {...modal} />
-  } else if (modal.name === 'preferences-home-feed') {
-    element = <PreferencesHomeFeed.Component />
   } else if (modal.name === 'moderation-details') {
     element = <ModerationDetailsModal.Component {...modal} />
   } else {
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 088be6a90..8b556cea3 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -367,6 +367,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             pal.border,
             pal.view,
             item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
+            styles.cursor,
           ]}
           moderation={item.moderation.content}>
           <PostSandboxWarning />
@@ -616,4 +617,8 @@ const styles = StyleSheet.create({
     marginLeft: 'auto',
     marginRight: 'auto',
   },
+  cursor: {
+    // @ts-ignore web only
+    cursor: 'pointer',
+  },
 })
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 94dfe6e8b..661b3a899 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -304,6 +304,7 @@ const styles = StyleSheet.create({
     paddingBottom: 5,
     paddingLeft: 10,
     borderTopWidth: 1,
+    cursor: 'pointer',
   },
   layout: {
     flexDirection: 'row',
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index e1212f32c..c46411f0f 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -343,6 +343,7 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
     paddingLeft: 10,
     paddingRight: 15,
+    cursor: 'pointer',
   },
   outerSmallTop: {
     borderTopWidth: 0,
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index ead85d0b5..321b6ab63 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -259,15 +259,21 @@ function onPressInner(
   e?: Event,
 ) {
   let shouldHandle = false
+  const isLeftClick =
+    // @ts-ignore Web only -prf
+    Platform.OS === 'web' && (e.button == null || e.button === 0)
+  // @ts-ignore Web only -prf
+  const isMiddleClick = Platform.OS === 'web' && e.button === 1
+  const isMetaKey =
+    // @ts-ignore Web only -prf
+    Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey)
+  const newTab = isMetaKey || isMiddleClick
 
   if (Platform.OS !== 'web' || !e) {
     shouldHandle = e ? !e.defaultPrevented : true
   } else if (
     !e.defaultPrevented && // onPress prevented default
-    // @ts-ignore Web only -prf
-    !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys
-    // @ts-ignore Web only -prf
-    (e.button == null || e.button === 0) && // ignore everything but left clicks
+    (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks
     // @ts-ignore Web only -prf
     [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc.
   ) {
@@ -277,7 +283,7 @@ function onPressInner(
 
   if (shouldHandle) {
     href = convertBskyAppUrlIfNeeded(href)
-    if (href.startsWith('http') || href.startsWith('mailto')) {
+    if (newTab || href.startsWith('http') || href.startsWith('mailto')) {
       Linking.openURL(href)
     } else {
       store.shell.closeModal() // close any active modals
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index cd3299284..8d2a30506 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -168,6 +168,7 @@ export function Selector({
         backgroundColor: pal.colors.background,
       }}>
       <ScrollView
+        testID="selector"
         horizontal
         showsHorizontalScrollIndicator={false}
         style={{position: 'absolute'}}>
diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 15f7625b5..b04f274f7 100644
--- a/src/view/com/modals/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -1,16 +1,16 @@
 import React, {useState} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {Slider} from '@miblanchard/react-native-slider'
-import {Text} from '../util/text/Text'
+import {Text} from '../com/util/text/Text'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isDesktopWeb} from 'platform/detection'
 import {ToggleButton} from 'view/com/util/forms/ToggleButton'
-import {ScrollView} from 'view/com/modals/util'
-
-export const snapPoints = ['90%']
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
 
 function RepliesThresholdInput({enabled}: {enabled: boolean}) {
   const store = useStores()
@@ -43,18 +43,25 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
   )
 }
 
-export const Component = observer(function Component() {
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesHomeFeed'
+>
+export const PreferencesHomeFeed = observer(({navigation}: Props) => {
   const pal = usePalette('default')
   const store = useStores()
 
   return (
-    <View
-      testID="preferencesHomeFeedModal"
-      style={[pal.view, styles.container]}>
+    <CenteredView
+      testID="preferencesHomeFeedScreen"
+      style={[
+        pal.view,
+        pal.border,
+        styles.container,
+        isDesktopWeb && styles.desktopContainer,
+      ]}>
+      <ViewHeader title="Home Feed Preferences" showOnDesktop />
       <View style={styles.titleSection}>
-        <Text type="title-lg" style={[pal.text, styles.title]}>
-          Home Feed Preferences
-        </Text>
         <Text type="xl" style={[pal.textLight, styles.description]}>
           Fine-tune the content you see on your home screen.
         </Text>
@@ -119,27 +126,33 @@ export const Component = observer(function Component() {
         <TouchableOpacity
           testID="confirmBtn"
           onPress={() => {
-            store.shell.closeModal()
+            navigation.canGoBack()
+              ? navigation.goBack()
+              : navigation.navigate('Settings')
           }}
-          style={[styles.btn]}
+          style={[styles.btn, isDesktopWeb && styles.btnDesktop]}
           accessibilityRole="button"
           accessibilityLabel="Confirm"
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>Done</Text>
         </TouchableOpacity>
       </View>
-    </View>
+    </CenteredView>
   )
 })
 
 const styles = StyleSheet.create({
   container: {
     flex: 1,
-    paddingBottom: isDesktopWeb ? 0 : 60,
+    paddingBottom: isDesktopWeb ? 40 : 90,
+  },
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
   },
   titleSection: {
-    padding: 20,
     paddingBottom: 30,
+    paddingTop: isDesktopWeb ? 20 : 0,
   },
   title: {
     textAlign: 'center',
@@ -165,9 +178,12 @@ const styles = StyleSheet.create({
     padding: 14,
     backgroundColor: colors.blue3,
   },
+  btnDesktop: {
+    marginHorizontal: 'auto',
+    paddingHorizontal: 80,
+  },
   btnContainer: {
     paddingTop: 20,
-    paddingHorizontal: 20,
     borderTopWidth: isDesktopWeb ? 0 : 1,
   },
   dimmed: {
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 651fac21f..3c50fdde0 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -74,7 +74,7 @@ export const ProfileListScreen = withAuthRequired(
       store.shell.openModal({
         name: 'confirm',
         title: 'Delete List',
-        message: 'Are you sure',
+        message: 'Are you sure?',
         async onPressConfirm() {
           await list.delete()
           if (navigation.canGoBack()) {
diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx
index 85e8c212e..3218b4579 100644
--- a/src/view/screens/Search.web.tsx
+++ b/src/view/screens/Search.web.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import {View, StyleSheet} from 'react-native'
 import {SearchUIModel} from 'state/models/ui/search'
 import {FoafsModel} from 'state/models/discovery/foafs'
 import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
@@ -47,13 +48,28 @@ export const SearchScreen = withAuthRequired(
     const {isDesktop} = useWebMediaQueries()
 
     if (searchUIModel) {
-      return <SearchResults model={searchUIModel} />
+      return (
+        <View style={styles.scrollContainer}>
+          <SearchResults model={searchUIModel} />
+        </View>
+      )
     }
 
     if (!isDesktop) {
-      return <Mobile.SearchScreen navigation={navigation} route={route} />
+      return (
+        <View style={styles.scrollContainer}>
+          <Mobile.SearchScreen navigation={navigation} route={route} />
+        </View>
+      )
     }
 
     return <Suggestions foafs={foafs} suggestedActors={suggestedActors} />
   }),
 )
+
+const styles = StyleSheet.create({
+  scrollContainer: {
+    height: '100%',
+    overflowY: 'auto',
+  },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 4a2c1c16a..481d77086 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -175,10 +175,8 @@ export const SettingsScreen = withAuthRequired(
     }, [])
 
     const openPreferencesModal = React.useCallback(() => {
-      store.shell.openModal({
-        name: 'preferences-home-feed',
-      })
-    }, [store])
+      navigation.navigate('PreferencesHomeFeed')
+    }, [navigation])
 
     const onPressAppPasswords = React.useCallback(() => {
       navigation.navigate('AppPasswords')
@@ -391,7 +389,7 @@ export const SettingsScreen = withAuthRequired(
             Advanced
           </Text>
           <TouchableOpacity
-            testID="preferencesHomeFeedModalButton"
+            testID="preferencesHomeFeedButton"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             onPress={openPreferencesModal}
             accessibilityRole="button"
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index b37befba6..eec55ee46 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -14,6 +14,7 @@ import {
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Link} from 'view/com/util/Link'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
@@ -40,10 +41,14 @@ import {makeProfileLink} from 'lib/routes/links'
 
 const ProfileCard = observer(() => {
   const store = useStores()
-  return (
+  return store.me.handle ? (
     <Link href={makeProfileLink(store.me)} style={styles.profileCard} asAnchor>
       <UserAvatar avatar={store.me.avatar} size={64} />
     </Link>
+  ) : (
+    <View style={styles.profileCard}>
+      <LoadingPlaceholder width={64} height={64} style={{borderRadius: 64}} />
+    </View>
   )
 })