about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/golang-test-lint.yml4
-rw-r--r--Dockerfile3
-rw-r--r--__e2e__/tests/mute-lists.test.ts4
-rw-r--r--__e2e__/tests/profile-screen.test.ts4
-rw-r--r--__e2e__/tests/self-labeling.test.ts2
-rw-r--r--__mocks__/@miblanchard/react-native-slider.js1
-rw-r--r--app.json6
-rw-r--r--bskyweb/Makefile3
-rw-r--r--bskyweb/README.md2
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--bskyweb/go.mod2
-rw-r--r--bskyweb/go.sum2
-rw-r--r--bskyweb/templates/base.html4
-rw-r--r--package.json7
-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
-rw-r--r--web/index.html4
-rw-r--r--yarn.lock31
41 files changed, 348 insertions, 792 deletions
diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml
index 096d1b933..2576c3479 100644
--- a/.github/workflows/golang-test-lint.yml
+++ b/.github/workflows/golang-test-lint.yml
@@ -19,7 +19,7 @@ jobs:
       - name: Set up Go tooling
         uses: actions/setup-go@v3
         with:
-          go-version: '1.20'
+          go-version: '1.21'
       - name: Dummy JS File
         run: touch bskyweb/static/js/blah.js
       - name: Check
@@ -36,7 +36,7 @@ jobs:
       - name: Set up Go tooling
         uses: actions/setup-go@v3
         with:
-          go-version: '1.20'
+          go-version: '1.21'
       - name: Dummy JS File
         run: touch bskyweb/static/js/blah.js
       - name: Lint
diff --git a/Dockerfile b/Dockerfile
index 241926db4..388b742cc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.20-bullseye AS build-env
+FROM golang:1.21-bullseye AS build-env
 
 WORKDIR /usr/src/social-app
 
@@ -13,6 +13,7 @@ ENV GODEBUG="netdns=go"
 ENV GOOS="linux"
 ENV GOARCH="amd64"
 ENV CGO_ENABLED=1
+ENV GOEXPERIMENT="loopvar"
 
 COPY . .
 
diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts
index 870e7ced2..1fd3dc328 100644
--- a/__e2e__/tests/mute-lists.test.ts
+++ b/__e2e__/tests/mute-lists.test.ts
@@ -114,7 +114,8 @@ describe('Mute lists', () => {
 
   it('Shows the mutelist on my profile', async () => {
     await element(by.id('bottomBarProfileBtn')).tap()
-    await element(by.id('selector-3')).tap()
+    await element(by.id('selector')).swipe('left')
+    await element(by.id('selector-4')).tap()
     await element(by.id('list-Bad Ppl')).tap()
   })
 
@@ -156,6 +157,7 @@ describe('Mute lists', () => {
     await element(by.id('bottomBarSearchBtn')).tap()
     await element(by.id('searchTextInput')).typeText('alice')
     await element(by.id('searchAutoCompleteResult-alice.test')).tap()
+    await element(by.id('selector')).swipe('left')
     await element(by.id('selector-3')).tap()
     await element(by.id('list-Bad Ppl')).tap()
     await element(by.id('reportListBtn')).tap()
diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts
index da7980094..92ed2dc65 100644
--- a/__e2e__/tests/profile-screen.test.ts
+++ b/__e2e__/tests/profile-screen.test.ts
@@ -18,8 +18,10 @@ describe('Profile screen', () => {
   })
 
   it('Can see feeds', async () => {
-    await element(by.id('selector-3')).tap()
+    await element(by.id('selector')).swipe('left')
+    await element(by.id('selector-4')).tap()
     await expect(element(by.id('feed-alices feed'))).toBeVisible()
+    await element(by.id('selector')).swipe('right')
     await element(by.id('selector-0')).tap()
   })
 
diff --git a/__e2e__/tests/self-labeling.test.ts b/__e2e__/tests/self-labeling.test.ts
index 70164cb85..ba8d00f21 100644
--- a/__e2e__/tests/self-labeling.test.ts
+++ b/__e2e__/tests/self-labeling.test.ts
@@ -20,7 +20,7 @@ describe('Self-labeling', () => {
     await element(by.id('composeFAB')).tap()
     await element(by.id('composerTextInput')).typeText('Post with an image')
     await element(by.id('openGalleryBtn')).tap()
-    await sleep(1e3)
+    await sleep(3e3)
     await element(by.id('labelsBtn')).tap()
     await element(by.id('pornLabelBtn')).tap()
     await element(by.id('confirmBtn')).tap()
diff --git a/__mocks__/@miblanchard/react-native-slider.js b/__mocks__/@miblanchard/react-native-slider.js
new file mode 100644
index 000000000..99c6a9251
--- /dev/null
+++ b/__mocks__/@miblanchard/react-native-slider.js
@@ -0,0 +1 @@
+export const Slider = {}
diff --git a/app.json b/app.json
index b58431f9e..f280bcd85 100644
--- a/app.json
+++ b/app.json
@@ -4,7 +4,7 @@
     "slug": "bluesky",
     "scheme": "bluesky",
     "owner": "blueskysocial",
-    "version": "1.47.0",
+    "version": "1.48.0",
     "runtimeVersion": {
       "policy": "appVersion"
     },
@@ -17,7 +17,7 @@
       "backgroundColor": "#ffffff"
     },
     "ios": {
-      "buildNumber": "3",
+      "buildNumber": "1",
       "supportsTablet": false,
       "bundleIdentifier": "xyz.blueskyweb.app",
       "config": {
@@ -39,7 +39,7 @@
       "backgroundColor": "#ffffff"
     },
     "android": {
-      "versionCode": 34,
+      "versionCode": 35,
       "adaptiveIcon": {
         "foregroundImage": "./assets/adaptive-icon.png",
         "backgroundColor": "#ffffff"
diff --git a/bskyweb/Makefile b/bskyweb/Makefile
index 7561a1454..e0ba8aec0 100644
--- a/bskyweb/Makefile
+++ b/bskyweb/Makefile
@@ -2,6 +2,9 @@
 SHELL = /bin/bash
 .SHELLFLAGS = -o pipefail -c
 
+# https://github.com/golang/go/wiki/LoopvarExperiment
+export GOEXPERIMENT := loopvar
+
 .PHONY: help
 help: ## Print info about all commands
 	@echo "Commands:"
diff --git a/bskyweb/README.md b/bskyweb/README.md
index d60647379..c8efe0448 100644
--- a/bskyweb/README.md
+++ b/bskyweb/README.md
@@ -24,7 +24,7 @@ Then build and copy over the big 'ol `bundle.web.js` file:
 
 ### Golang Daemon
 
-Install golang. We are generally using v1.20+.
+Install golang. We are generally using v1.21+.
 
 In this directory (`bskyweb/`):
 
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 5cc4ef663..9a8f3bbdd 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -168,6 +168,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/moderation/blocked-accounts", server.WebGeneric)
 	e.GET("/settings", server.WebGeneric)
 	e.GET("/settings/app-passwords", server.WebGeneric)
+	e.GET("/settings/home-feed", server.WebGeneric)
 	e.GET("/settings/saved-feeds", server.WebGeneric)
 	e.GET("/sys/debug", server.WebGeneric)
 	e.GET("/sys/log", server.WebGeneric)
diff --git a/bskyweb/go.mod b/bskyweb/go.mod
index 5f06bfc45..bc513727c 100644
--- a/bskyweb/go.mod
+++ b/bskyweb/go.mod
@@ -1,6 +1,6 @@
 module github.com/bluesky-social/social-app/bskyweb
 
-go 1.20
+go 1.21
 
 require (
 	github.com/bluesky-social/indigo v0.0.0-20230504025040-8915cccc3319
diff --git a/bskyweb/go.sum b/bskyweb/go.sum
index ae5d7defb..a07e446f4 100644
--- a/bskyweb/go.sum
+++ b/bskyweb/go.sum
@@ -31,6 +31,7 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
@@ -240,6 +241,7 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
 go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
+go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 5a671d6ad..8cb0bcd4f 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -113,9 +113,9 @@
     .ProseMirror .mention {
       color: #0085ff;
     }
-    .ProseMirror a {
+    .ProseMirror a,
+    .ProseMirror .autolink {
       color: #0085ff;
-      cursor: pointer;
     }
     /* OLLIE: TODO -- this is not accessible */
     /* Remove focus state on inputs */
diff --git a/package.json b/package.json
index b62fad03f..f11d482ff 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.47.0",
+  "version": "1.48.0",
   "private": true,
   "scripts": {
     "prepare": "is-ci || husky install",
@@ -24,7 +24,7 @@
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "^0.6.6",
+    "@atproto/api": "^0.6.8",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
@@ -36,7 +36,7 @@
     "@fortawesome/react-native-fontawesome": "^0.3.0",
     "@gorhom/bottom-sheet": "^4.4.7",
     "@mattermost/react-native-paste-input": "^0.6.4",
-    "@miblanchard/react-native-slider": "^2.2.0",
+    "@miblanchard/react-native-slider": "^2.3.1",
     "@react-native-async-storage/async-storage": "^1.17.6",
     "@react-native-camera-roll/camera-roll": "^5.2.2",
     "@react-native-clipboard/clipboard": "^1.10.0",
@@ -56,7 +56,6 @@
     "@tiptap/extension-document": "^2.0.0-beta.220",
     "@tiptap/extension-hard-break": "^2.0.3",
     "@tiptap/extension-history": "^2.0.3",
-    "@tiptap/extension-link": "^2.0.0-beta.220",
     "@tiptap/extension-mention": "^2.0.0-beta.220",
     "@tiptap/extension-paragraph": "^2.0.0-beta.220",
     "@tiptap/extension-placeholder": "^2.0.0-beta.220",
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>
   )
 })
 
diff --git a/web/index.html b/web/index.html
index 603e3954d..e8f4eb47e 100644
--- a/web/index.html
+++ b/web/index.html
@@ -117,9 +117,9 @@
       .ProseMirror .mention {
         color: #0085ff;
       }
-      .ProseMirror a {
+      .ProseMirror a, 
+      .ProseMirror .autolink {
         color: #0085ff;
-        cursor: pointer;
       }
       /* OLLIE: TODO -- this is not accessible */
       /* Remove focus state on inputs */
diff --git a/yarn.lock b/yarn.lock
index aab3e6a15..a0704f071 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -45,13 +45,13 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@^0.6.6":
-  version "0.6.6"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.6.tgz#c1bfdb6bc7dee9cdba1901cde0081c2d422d7c29"
-  integrity sha512-j+yNTjllVxuTc4bAegghTopju7MdhczLXWvWIli40uXwCzQ3JjS1mFr/47eETtysib2phWYQvfhtCrqQq6AAig==
+"@atproto/api@^0.6.8":
+  version "0.6.8"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.6.8.tgz#fe77f3ab2e7a815edca1357b0a89a7496be8f764"
+  integrity sha512-WmXpIbO79f85UA8AzvvSqKibojBXN1HT3KEHhUOqXJRW8X1trYijgWIXwhnxhoBQXgiQfzKG7HdORvRjmRSLoQ==
   dependencies:
     "@atproto/common-web" "*"
-    "@atproto/uri" "*"
+    "@atproto/syntax" "*"
     "@atproto/xrpc" "*"
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
@@ -317,6 +317,13 @@
     uint8arrays "3.0.0"
     zod "^3.21.4"
 
+"@atproto/syntax@*":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.1.0.tgz#f13b9dad8d13342cc54295ecd702ea13c82c9bf0"
+  integrity sha512-Ktui0qvIXt1o1Px1KKC0eqn69MfRHQ9ok5EwjcxIEPbJ8OY5XqkeyJneFDIWRJZiR6vqPHfjFYRUpTB+jNPfRQ==
+  dependencies:
+    "@atproto/common-web" "*"
+
 "@atproto/uri@*":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@atproto/uri/-/uri-0.1.0.tgz#1cb4695d2f1766ec8d542af6a495a416f6f6c214"
@@ -3490,7 +3497,7 @@
   dependencies:
     semver "7.5.4"
 
-"@miblanchard/react-native-slider@^2.2.0":
+"@miblanchard/react-native-slider@^2.3.1":
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.3.1.tgz#79e0f1f9b1ce43ef25ee51ee9256c012e5dfa412"
   integrity sha512-J/hZDBWmXq8fJeOnTVHqIUVDHshqMSpJVxJ4WqwuCBKl5Rke9OBYXIdkSlgi75OgtScAr8FKK5KNkDKHUf6JIg==
@@ -5957,13 +5964,6 @@
   resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.1.6.tgz#799d7412c270e29b1fcaa08cd8343724b88eb6a6"
   integrity sha512-ltHz9cW3bWi7Z3m960F5eLPAqZDBNOpUP31t9YdKqhyxA16eygryj1USVeus9DX5OBoW79I8EecFAuRo3Rymlw==
 
-"@tiptap/extension-link@^2.0.0-beta.220":
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.1.6.tgz#facdea5beef493226d4b240770b0c68686247793"
-  integrity sha512-at4tUpb8P2mMqc3jGMsggoKrt2mMWX0uNvoFYpKpnptQvsweCXSV5xi60o1C5kL7f0v/FYvEk4QaQBJmG5DmRg==
-  dependencies:
-    linkifyjs "^4.1.0"
-
 "@tiptap/extension-mention@^2.0.0-beta.220":
   version "2.1.6"
   resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.1.6.tgz#70ee72f917c11c6f2542e9cffc8afb38c3719749"
@@ -13973,11 +13973,6 @@ linkify-it@^4.0.1:
   dependencies:
     uc.micro "^1.0.1"
 
-linkifyjs@^4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.1.tgz#73d427e3bbaaf4ca8e71c589ad4ffda11a9a5fde"
-  integrity sha512-zFN/CTVmbcVef+WaDXT63dNzzkfRBKT1j464NJQkV7iSgJU0sLBus9W0HBwnXK13/hf168pbrx/V/bjEHOXNHA==
-
 lint-staged@^13.2.3:
   version "13.3.0"
   resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.3.0.tgz#7965d72a8d6a6c932f85e9c13ccf3596782d28a5"