about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.example1
-rw-r--r--.github/ISSUE_TEMPLATE/bug_report.md35
-rw-r--r--.github/ISSUE_TEMPLATE/feature_request.md24
-rw-r--r--Dockerfile3
-rw-r--r--LICENSE7
-rw-r--r--Makefile35
-rw-r--r--README.md167
-rw-r--r--__e2e__/mock-server.ts24
-rw-r--r--__e2e__/tests/create-account.test.ts1
-rw-r--r--__e2e__/tests/invite-codes.test.ts1
-rw-r--r--__e2e__/tests/mute-lists.test.ts141
-rw-r--r--__e2e__/util.ts1
-rw-r--r--app.json4
-rw-r--r--bskyweb/cmd/bskyweb/server.go7
-rw-r--r--bskyweb/templates/base.html2
-rw-r--r--docs/build.md109
-rw-r--r--eas.json15
-rw-r--r--jest/test-pds.ts26
-rw-r--r--package.json10
-rw-r--r--src/Navigation.tsx153
-rw-r--r--src/lib/constants.ts36
-rw-r--r--src/lib/hooks/useSetTitle.ts20
-rw-r--r--src/lib/icons.tsx55
-rw-r--r--src/lib/labeling/helpers.ts33
-rw-r--r--src/lib/labeling/types.ts4
-rw-r--r--src/lib/media/picker.tsx49
-rw-r--r--src/lib/routes/types.ts9
-rw-r--r--src/lib/strings/display-names.ts15
-rw-r--r--src/lib/strings/headings.ts4
-rw-r--r--src/lib/strings/url-helpers.ts9
-rw-r--r--src/routes.ts9
-rw-r--r--src/state/models/content/list-membership.ts112
-rw-r--r--src/state/models/content/list.ts257
-rw-r--r--src/state/models/content/post-thread.ts4
-rw-r--r--src/state/models/content/profile.ts4
-rw-r--r--src/state/models/feeds/notifications.ts1
-rw-r--r--src/state/models/feeds/posts.ts4
-rw-r--r--src/state/models/lists/lists-list.ts214
-rw-r--r--src/state/models/media/gallery.ts18
-rw-r--r--src/state/models/media/image.ts94
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/state/models/ui/preferences.ts84
-rw-r--r--src/state/models/ui/profile.ts24
-rw-r--r--src/state/models/ui/shell.ts18
-rw-r--r--src/view/com/composer/photos/Gallery.tsx10
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx12
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx5
-rw-r--r--src/view/com/lists/ListCard.tsx155
-rw-r--r--src/view/com/lists/ListItems.tsx387
-rw-r--r--src/view/com/lists/ListsList.tsx240
-rw-r--r--src/view/com/modals/ChangeHandle.tsx7
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx75
-rw-r--r--src/view/com/modals/CreateOrEditMuteList.tsx279
-rw-r--r--src/view/com/modals/EditImage.tsx375
-rw-r--r--src/view/com/modals/EditProfile.tsx4
-rw-r--r--src/view/com/modals/InviteCodes.tsx2
-rw-r--r--src/view/com/modals/ListAddRemoveUser.tsx253
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx6
-rw-r--r--src/view/com/modals/ReportPost.tsx15
-rw-r--r--src/view/com/pager/TabBar.tsx19
-rw-r--r--src/view/com/post-thread/PostThread.tsx9
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx2
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx20
-rw-r--r--src/view/com/posts/FeedSlice.tsx4
-rw-r--r--src/view/com/profile/ProfileCard.tsx4
-rw-r--r--src/view/com/profile/ProfileHeader.tsx56
-rw-r--r--src/view/com/util/BlurView.web.tsx3
-rw-r--r--src/view/com/util/EmptyState.tsx4
-rw-r--r--src/view/com/util/EmptyStateWithButton.tsx88
-rw-r--r--src/view/com/util/UserAvatar.tsx22
-rw-r--r--src/view/com/util/UserBanner.tsx6
-rw-r--r--src/view/com/util/ViewHeader.tsx19
-rw-r--r--src/view/com/util/Views.web.tsx8
-rw-r--r--src/view/com/util/forms/Button.tsx22
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx6
-rw-r--r--src/view/com/util/images/Gallery.tsx1
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx (renamed from src/view/com/util/PostCtrls.tsx)90
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx95
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx86
-rw-r--r--src/view/com/util/post-embeds/index.tsx1
-rw-r--r--src/view/index.ts10
-rw-r--r--src/view/screens/AppPasswords.tsx5
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Moderation.tsx137
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx (renamed from src/view/screens/BlockedAccounts.tsx)7
-rw-r--r--src/view/screens/ModerationMuteLists.tsx122
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx (renamed from src/view/screens/MutedAccounts.tsx)7
-rw-r--r--src/view/screens/Profile.tsx109
-rw-r--r--src/view/screens/ProfileList.tsx177
-rw-r--r--src/view/screens/SearchMobile.tsx2
-rw-r--r--src/view/screens/Settings.tsx60
-rw-r--r--src/view/shell/Drawer.tsx27
-rw-r--r--src/view/shell/bottom-bar/BottomBar.tsx9
-rw-r--r--src/view/shell/desktop/LeftNav.tsx21
-rw-r--r--src/view/shell/desktop/RightNav.tsx11
-rw-r--r--src/view/shell/index.web.tsx12
-rw-r--r--yarn.lock31
99 files changed, 4257 insertions, 739 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..b4213aea2
--- /dev/null
+++ b/.env.example
@@ -0,0 +1 @@
+SENTRY_AUTH_TOKEN=
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..c608f6600
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+<!-- A clear and concise description of what the bug is. -->
+
+**To Reproduce**
+
+Steps to reproduce the behavior:
+
+1.
+
+**Expected behavior**
+
+<!-- A clear and concise description of what you expected to happen. -->
+
+**Screenshots**
+
+<!-- If applicable, add screenshots to help explain your problem. -->
+
+**Details**
+
+ - Platform: <!-- desktop chrome windows, mobile safari, iOS, Android -->
+ - Platform version:
+ - App version:
+
+**Additional context**
+
+<!-- Add any other context about the problem here. -->
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..0ac6b3c41
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,24 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: ''
+labels: feature-request
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+
+<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
+
+**Describe the solution you'd like**
+
+<!-- A clear and concise description of what you want to happen. -->
+
+**Describe alternatives you've considered**
+
+<!-- A clear and concise description of any alternative solutions or features you've considered. -->
+
+**Additional context**
+
+<!-- Add any other context or screenshots about the feature request here. -->
diff --git a/Dockerfile b/Dockerfile
index 95f0ec02e..fbd13bebf 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -35,9 +35,6 @@ RUN \. "$NVM_DIR/nvm.sh" && \
 # DEBUG
 RUN find ./bskyweb/static && find ./web-build/static
 
-# Copy the bundle js files.
-RUN cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/
-
 #
 # Generate the bksyweb Go binary.
 #
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 000000000..801636644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,7 @@
+Copyright 2023 Bluesky PBLLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 000000000..e93b6357a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,35 @@
+
+SHELL = /bin/bash
+.SHELLFLAGS = -o pipefail -c
+
+.PHONY: help
+help: ## Print info about all commands
+	@echo "Commands:"
+	@echo
+	@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "    \033[01;32m%-20s\033[0m %s\n", $$1, $$2}'
+
+.PHONY: build-web
+build-web: ## Compile web bundle, copy to bskyweb directory
+	yarn build-web
+
+.PHONY: test
+test: ## Run all tests
+	yarn test
+
+.PHONY: lint
+lint: ## Run style checks and verify syntax
+	yarn run lint
+
+#.PHONY: fmt
+#fmt: ## Run syntax re-formatting
+#	yarn prettier
+
+.PHONY: deps
+deps: ## Installs dependent libs using 'yarn install'
+	yarn install --frozen-lockfile
+
+.PHONY: nvm-setup
+nvm-setup: ## Use NVM to install and activate node+yarn
+	nvm install 18
+	nvm use 18
+	npm install --global yarn
diff --git a/README.md b/README.md
index b392579d9..1782b8ee2 100644
--- a/README.md
+++ b/README.md
@@ -1,106 +1,61 @@
-# Bluesky
-
-## Build instructions
-
-- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
-- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
-  - yarn global add detox-cli
-  - brew tap wix/brew
-  - brew install applesimutils
-- After initial setup:
-  - `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes
-- Start the dev servers
-  - `git clone git@github.com:bluesky-social/atproto.git`
-  - `cd atproto`
-  - `yarn`
-  - `cd packages/dev-env && yarn start`
-- Run the dev app
-  - iOS: `yarn ios`
-  - Android: `yarn android`
-  - Web: `yarn web`
-- Run e2e tests
-  - Start in various console tabs:
-    - `yarn e2e:server`
-    - `yarn e2e:metro`
-  - Run once: `yarn e2e:build`
-  - Each test run: `yarn e2e:run`
-- Tips
-  - `npx react-native info` Checks what has been installed.
-  - The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
-    - For instance, the localhosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
-  - For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should bee the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
-
-## Build instructions (with Go)
-
-### Prerequisites
-
-- [Go](https://go.dev/)
-- [Yarn](https://yarnpkg.com/)
-
-### Steps
-
-To run the build with Go, use staging credentials, your own, or any other account you create.
-
-```
-cd social-app
-yarn && yarn build-web
-cp ./web-build/static/js/*.* bskyweb/static/js/
-cd bskyweb/
-go mod tidy
-go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
-./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
-```
-
-On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
-
-## Various notes
-
-### Debugging
-
-- Note that since 0.70, debugging using the old debugger (which shows up using CMD+D) doesn't work anymore. Follow the instructions below to debug the code: https://reactnative.dev/docs/next/hermes#debugging-js-on-hermes-using-google-chromes-devtools
-
-### Developer Menu
-
-To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-menu) on an `expo-dev-client` app you can do the following:
-
-- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal
-- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal
-- iOS Device: Shake the device, or touch 3 fingers to the screen
-- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d
-
-### Running E2E Tests
-
-- Make sure you've setup your environment following above
-- Make sure Metro and the dev server are running
-- Run `yarn e2e`
-- Find the artifacts in the `artifact` folder
-
-### Polyfills
-
-`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
-
-- TextEncoder / TextDecoder
-
-### Sentry sourcemaps
-
-Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry:
-
-- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step.
-- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS).
-- Next, you can use the Sentry CLI to upload your bundles and source maps:
-  - release name should be set to `${bundleIdentifier}@${version}+${buildNumber}` (iOS) or `${androidPackage}@${version}+${versionCode}` (Android), so for example `com.domain.myapp@1.0.0+1`.
-  - `dist` should be set to the Update ID that `eas update` generated.
-- Command for Android:
-  `node_modules/@sentry/cli/bin/sentry-cli releases \
-files <release name> \
-upload-sourcemaps \
---dist <Android Update ID> \
---rewrite \
-dist/bundles/index.android.bundle dist/bundles/android-<hash>.map`
-- Command for iOS:
-  `node_modules/@sentry/cli/bin/sentry-cli releases \
-files <release name> \
-upload-sourcemaps \
---dist <iOS Update ID> \
---rewrite \
-dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
+# Bluesky Social App
+
+Welcome friends! This is the codebase for the Bluesky Social app. It serves as a resource to engineers building on the [AT Protocol](https://atproto.com).
+
+- **Web: [staging.bsky.app](https://staging.bsky.app)**
+- **iOS: [App Store](https://apps.apple.com/us/app/bluesky-social/id6444370199)**
+- **Android: [Play Store](https://play.google.com/store/apps/details?id=xyz.blueskyweb.app&hl=en_US&gl=US)**
+
+Links:
+
+- [Build instructions](./docs/build.md)
+- [ATProto repo](https://github.com/bluesky-social/atproto)
+- [ATProto docs](https://atproto.com)
+
+## Rules & guidelines
+
+--- 
+
+ℹ️ While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
+
+---
+
+**Rules:**
+
+- We may not respond to your issue or PR.
+- We may close an issue or PR without much feedback.
+- We may lock discussions or contributions if our attention is getting DDOSed.
+- We're not going to provide support for build issues.
+
+**Guidelines:**
+
+- Check for existing issues before filing a new one please.
+- Open an issue and give some time for discussion before submitting a PR.
+- Stay away from PRs like...
+  - Changing "Post" to "Skeet."
+  - Refactoring the codebase, eg to replace mobx with redux or something.
+  - Adding entirely new features without prior discussion. 
+
+Remember, we serve a wide community of users. Our day to day involves us constantly asking "which top priority is our top priority." If you submit well-written PRs that solve problems concisely, that's an awesome contribution. Otherwise, as much as we'd love to accept your ideas and contributions, we really don't have the bandwidth. That's what forking is for!
+
+## Forking guidelines
+
+You have our blessing 🪄✨ to fork this application! However, it's very important to be clear to users when you're giving them a fork.
+
+Please be sure to:
+
+- Change all branding in the repository and UI to clearly differentiate from Bluesky.
+- Change any support links (feedback, email, terms of service, etc) to your own systems.
+- Replace any analytics or error-collection systems with your own so we don't get super confused.
+
+## Security disclosures
+
+If you discover any security issues, please send an email to security@bsky.app. The email is automatically CCed to the entire team and we'll respond promptly.
+
+## License (MIT)
+
+See [./LICENSE](./LICENSE) for the full license.
+
+## P.S.
+
+We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts
index 6744f697f..6ddfe3ca0 100644
--- a/__e2e__/mock-server.ts
+++ b/__e2e__/mock-server.ts
@@ -91,6 +91,7 @@ async function main() {
             'always-warn-profile',
             'always-warn-posts',
             'muted-account',
+            'muted-by-list-account',
           ]) {
             await server.mocker.createUser(user)
             await server.mocker.follow('alice', user)
@@ -258,11 +259,32 @@ async function main() {
           await server.mocker.createPost('muted-account', 'muted post')
           await server.mocker.createQuotePost(
             'muted-account',
-            'account quote post',
+            'muted quote post',
             anchorPost,
           )
           await server.mocker.createReply(
             'muted-account',
+            'muted reply',
+            anchorPost,
+          )
+
+          const list = await server.mocker.createMuteList(
+            'alice',
+            'Muted Users',
+          )
+          await server.mocker.addToMuteList(
+            'alice',
+            list,
+            server.mocker.users['muted-by-list-account'].did,
+          )
+          await server.mocker.createPost('muted-by-list-account', 'muted post')
+          await server.mocker.createQuotePost(
+            'muted-by-list-account',
+            'account quote post',
+            anchorPost,
+          )
+          await server.mocker.createReply(
+            'muted-by-list-account',
             'account reply',
             anchorPost,
           )
diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts
index 38466ed8e..7db4e912a 100644
--- a/__e2e__/tests/create-account.test.ts
+++ b/__e2e__/tests/create-account.test.ts
@@ -20,7 +20,6 @@ describe('Create account', () => {
     await element(by.id('nextBtn')).tap()
     await element(by.id('emailInput')).typeText('example@test.com')
     await element(by.id('passwordInput')).typeText('hunter2')
-    await element(by.id('is13Input')).tap()
     await device.takeScreenshot('4- entered account details')
     await element(by.id('nextBtn')).tap()
     await element(by.id('handleInput')).typeText('e2e-test')
diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test.ts
index e3bb5d7f2..846d3b768 100644
--- a/__e2e__/tests/invite-codes.test.ts
+++ b/__e2e__/tests/invite-codes.test.ts
@@ -37,7 +37,6 @@ describe('invite-codes', () => {
     await element(by.id('inviteCodeInput')).typeText(inviteCode)
     await element(by.id('emailInput')).typeText('example@test.com')
     await element(by.id('passwordInput')).typeText('hunter2')
-    await element(by.id('is13Input')).tap()
     await device.takeScreenshot('4- entered account details')
     await element(by.id('nextBtn')).tap()
     await element(by.id('handleInput')).typeText('e2e-test')
diff --git a/__e2e__/tests/mute-lists.test.ts b/__e2e__/tests/mute-lists.test.ts
new file mode 100644
index 000000000..e93162513
--- /dev/null
+++ b/__e2e__/tests/mute-lists.test.ts
@@ -0,0 +1,141 @@
+/* eslint-env detox/detox */
+
+import {openApp, login, createServer, sleep} from '../util'
+
+describe('Profile screen', () => {
+  let service: string
+  beforeAll(async () => {
+    service = await createServer('?users&follows&labels')
+    await openApp({
+      permissions: {notifications: 'YES', medialibrary: 'YES', photos: 'YES'},
+    })
+  })
+
+  it('Login and view my mutelists', async () => {
+    await expect(element(by.id('signInButton'))).toBeVisible()
+    await login(service, 'alice', 'hunter2')
+    await element(by.id('viewHeaderDrawerBtn')).tap()
+    await expect(element(by.id('drawer'))).toBeVisible()
+    await element(by.id('menuItemButton-Moderation')).tap()
+    await element(by.id('mutelistsBtn')).tap()
+    await expect(element(by.id('list-Muted Users'))).toBeVisible()
+    await element(by.id('list-Muted Users')).tap()
+    await expect(
+      element(by.id('user-muted-by-list-account.test')),
+    ).toBeVisible()
+  })
+
+  it('Toggle subscription', async () => {
+    await element(by.id('unsubscribeListBtn')).tap()
+    await element(by.id('subscribeListBtn')).tap()
+  })
+
+  it('Edit display name and description via the edit mutelist modal', async () => {
+    await element(by.id('editListBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+    await element(by.id('editNameInput')).clearText()
+    await element(by.id('editNameInput')).typeText('Bad Ppl')
+    await element(by.id('editDescriptionInput')).clearText()
+    await element(by.id('editDescriptionInput')).typeText('They bad')
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
+    await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
+    await expect(element(by.id('listDescription'))).toHaveText('They bad')
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('editListBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+  })
+
+  it('Remove description via the edit mutelist modal', async () => {
+    await element(by.id('editListBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+    await element(by.id('editDescriptionInput')).clearText()
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
+    await expect(element(by.id('listDescription'))).not.toBeVisible()
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('editListBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+  })
+
+  it('Set avi via the edit mutelist modal', async () => {
+    await expect(element(by.id('userAvatarFallback'))).toExist()
+    await element(by.id('editListBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+    await element(by.id('changeAvatarBtn')).tap()
+    await element(by.id('changeAvatarLibraryBtn')).tap()
+    await sleep(3e3)
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
+    await expect(element(by.id('userAvatarImage'))).toExist()
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('editListBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+  })
+
+  it('Remove avi via the edit mutelist modal', async () => {
+    await expect(element(by.id('userAvatarImage'))).toExist()
+    await element(by.id('editListBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+    await element(by.id('changeAvatarBtn')).tap()
+    await element(by.id('changeAvatarRemoveBtn')).tap()
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
+    await expect(element(by.id('userAvatarFallback'))).toExist()
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('editListBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+  })
+
+  it('Delete the mutelist', async () => {
+    await element(by.id('deleteListBtn')).tap()
+    await element(by.id('confirmBtn')).tap()
+    await expect(element(by.id('emptyMuteLists'))).toBeVisible()
+  })
+
+  it('Create a new mutelist', async () => {
+    await element(by.id('emptyMuteLists-button')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).toBeVisible()
+    await element(by.id('editNameInput')).typeText('Bad Ppl')
+    await element(by.id('editDescriptionInput')).typeText('They bad')
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('createOrEditMuteListModal'))).not.toBeVisible()
+    await expect(element(by.id('listName'))).toHaveText('Bad Ppl')
+    await expect(element(by.id('listDescription'))).toHaveText('They bad')
+    // have to wait for the toast to clear
+    await waitFor(element(by.id('editListBtn')))
+      .toBeVisible()
+      .withTimeout(5000)
+  })
+
+  it('Shows the mutelist on my profile', async () => {
+    await element(by.id('bottomBarProfileBtn')).tap()
+    await element(by.id('selector-2')).tap()
+    await element(by.id('list-Bad Ppl')).tap()
+  })
+
+  it('Adds and removes users on mutelists', async () => {
+    await element(by.id('bottomBarSearchBtn')).tap()
+    await element(by.id('searchTextInput')).typeText('bob')
+    await element(by.id('searchAutoCompleteResult-bob.test')).tap()
+    await expect(element(by.id('profileView'))).toBeVisible()
+
+    await element(by.id('profileHeaderDropdownBtn')).tap()
+    await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap()
+    await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
+    await element(by.id('toggleBtn-Bad Ppl')).tap()
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
+
+    await element(by.id('profileHeaderDropdownBtn')).tap()
+    await element(by.id('profileHeaderDropdownListAddRemoveBtn')).tap()
+    await expect(element(by.id('listAddRemoveUserModal'))).toBeVisible()
+    await element(by.id('toggleBtn-Bad Ppl')).tap()
+    await element(by.id('saveBtn')).tap()
+    await expect(element(by.id('listAddRemoveUserModal'))).not.toBeVisible()
+  })
+})
diff --git a/__e2e__/util.ts b/__e2e__/util.ts
index 78d9f9f5d..d53c72dfe 100644
--- a/__e2e__/util.ts
+++ b/__e2e__/util.ts
@@ -42,6 +42,7 @@ export async function login(
     await device.takeScreenshot('2- opened service selector')
   }
   await element(by.id('customServerTextInput')).typeText(service)
+  await element(by.id('customServerTextInput')).tapReturnKey()
   await element(by.id('customServerSelectBtn')).tap()
   if (takeScreenshots) {
     await device.takeScreenshot('3- input custom service')
diff --git a/app.json b/app.json
index c1dc592cc..8a2fe039a 100644
--- a/app.json
+++ b/app.json
@@ -14,7 +14,7 @@
       "backgroundColor": "#ffffff"
     },
     "ios": {
-      "buildNumber": "2",
+      "buildNumber": "3",
       "supportsTablet": false,
       "bundleIdentifier": "xyz.blueskyweb.app",
       "config": {
@@ -40,7 +40,7 @@
       "backgroundColor": "#ffffff"
     },
     "android": {
-      "versionCode": 13,
+      "versionCode": 14,
       "adaptiveIcon": {
         "foregroundImage": "./assets/adaptive-icon.png",
         "backgroundColor": "#ffffff"
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 902d1ffc1..7c230041e 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -106,10 +106,12 @@ func serve(cctx *cli.Context) error {
 	// generic routes
 	e.GET("/search", server.WebGeneric)
 	e.GET("/notifications", server.WebGeneric)
+	e.GET("/moderation", server.WebGeneric)
+	e.GET("/moderation/mute-lists", server.WebGeneric)
+	e.GET("/moderation/muted-accounts", server.WebGeneric)
+	e.GET("/moderation/blocked-accounts", server.WebGeneric)
 	e.GET("/settings", server.WebGeneric)
 	e.GET("/settings/app-passwords", server.WebGeneric)
-	e.GET("/settings/muted-accounts", server.WebGeneric)
-	e.GET("/settings/blocked-accounts", server.WebGeneric)
 	e.GET("/sys/debug", server.WebGeneric)
 	e.GET("/sys/log", server.WebGeneric)
 	e.GET("/support", server.WebGeneric)
@@ -122,6 +124,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/profile/:handle", server.WebProfile)
 	e.GET("/profile/:handle/follows", server.WebGeneric)
 	e.GET("/profile/:handle/followers", server.WebGeneric)
+	e.GET("/profile/:handle/lists/:rkey", server.WebGeneric)
 
 	// post endpoints; only first populates info
 	e.GET("/profile/:handle/post/:rkey", server.WebPost)
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 1d51b4f20..5725b7af0 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -3,7 +3,7 @@
 <head>   
   <meta charset="UTF-8" />
   <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
-  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1.00001, viewport-fit=cover">
+  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
   <meta name="referrer" content="origin-when-cross-origin">
   <title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
 
diff --git a/docs/build.md b/docs/build.md
new file mode 100644
index 000000000..33e7f6751
--- /dev/null
+++ b/docs/build.md
@@ -0,0 +1,109 @@
+# Build instructions
+
+## App Build
+
+- Setup your environment [using the react native instructions](https://reactnative.dev/docs/environment-setup).
+- Setup your environment [for e2e testing using detox](https://wix.github.io/Detox/docs/introduction/getting-started):
+  - yarn global add detox-cli
+  - brew tap wix/brew
+  - brew install applesimutils
+- After initial setup:
+  - `npx expo prebuild` -> you will also need to run this anytime `app.json` or `package.json` changes
+- Start the dev servers
+  - `git clone git@github.com:bluesky-social/atproto.git`
+  - `cd atproto`
+  - `yarn`
+  - `cd packages/dev-env && yarn start`
+- Run the dev app
+  - iOS: `yarn ios`
+  - Android: `yarn android`
+  - Web: `yarn web`
+- If you are cloning or forking this repo as an open source developer, please check the tips below as well
+- Run e2e tests
+  - Start in various console tabs:
+    - `yarn e2e:mock-server`
+    - `yarn e2e:metro`
+  - Run once: `yarn e2e:build`
+  - Each test run: `yarn e2e:run`
+- Tips
+  - Make sure you copy the `.env.example` to `.env` and add the appropiate tokens (e.g. `SENTRY_AUTH_TOKEN` can be created on the Sentry dashboard). If this is not required, you can remove it from `eas.json` and `package.json`, as well as any mentions in the code.
+  - If you want to use Expo EAS on your own builds without ejecting from Expo, make sure to change the `owner` as well as `extra.eas.projectId` properties. If you do not have an Expo account, you may remove these properties.
+  - `npx react-native info` Checks what has been installed.
+  - The android simulator won't be able to access localhost services unless you run `adb reverse tcp:{PORT} tcp:{PORT}`
+    - For instance, the localhosted dev-wallet will need `adb reverse tcp:3001 tcp:3001`
+  - For some reason, the typescript compiler chokes on platform-specific files (e.g. `foo.native.ts`) but only when compiling for Web thus far. Therefore we always have one version of the file which doesn't use a platform specifier, and that should be the Web version. ([More info](https://stackoverflow.com/questions/44001050/platform-specific-import-component-in-react-native-with-typescript).)
+
+## Go-Server Build
+
+### Prerequisites
+
+- [Go](https://go.dev/)
+- [Yarn](https://yarnpkg.com/)
+
+### Steps
+
+To run the build with Go, use staging credentials, your own, or any other account you create.
+
+```
+cd social-app
+yarn && yarn build-web
+cp ./web-build/static/js/*.* bskyweb/static/js/
+cd bskyweb/
+go mod tidy
+go build -v -tags timetzdata -o bskyweb ./cmd/bskyweb
+./bskyweb serve --pds-host=https://staging.bsky.dev --handle=<HANDLE> --password=<PASSWORD>
+```
+
+On build success, access the application at [http://localhost:8100/](http://localhost:8100/). Subsequent changes require re-running the above steps in order to be reflected.
+
+## Various notes
+
+### Debugging
+
+- Note that since 0.70, debugging using the old debugger (which shows up using CMD+D) doesn't work anymore. Follow the instructions below to debug the code: https://reactnative.dev/docs/next/hermes#debugging-js-on-hermes-using-google-chromes-devtools
+
+### Developer Menu
+
+To open the [Developer Menu](https://docs.expo.dev/debugging/tools/#developer-menu) on an `expo-dev-client` app you can do the following:
+
+- Android Device: Shake the device vertically, or if your device is connected via USB, run adb shell input keyevent 82 in your terminal
+- Android Emulator: Either press Cmd ⌘ + m or Ctrl + m or run adb shell input keyevent 82 in your terminal
+- iOS Device: Shake the device, or touch 3 fingers to the screen
+- iOS Simulator: Press Ctrl + Cmd ⌘ + z on a Mac in the emulator to simulate the shake gesture, or press Cmd ⌘ + d
+
+### Running E2E Tests
+
+- Make sure you've setup your environment following above
+- Make sure Metro and the dev server are running
+- Run `yarn e2e`
+- Find the artifacts in the `artifact` folder
+
+### Polyfills
+
+`./platform/polyfills.*.ts` adds polyfills to the environment. Currently this includes:
+
+- TextEncoder / TextDecoder
+
+### Sentry sourcemaps
+
+Sourcemaps should automatically be updated when a signed build is created using `eas build` and published using `eas submit` due to the postPublish hook setup in `app.json`. However, if an update is created and published OTA using `eas update`, we need to the take the following steps to upload sourcemaps to Sentry:
+
+- Run eas update. This will generate a dist folder in your project root, which contains your JavaScript bundles and source maps. This command will also output the 'Android update ID' and 'iOS update ID' that we'll need in the next step.
+- Copy or rename the bundle names in the `dist/bundles` folder to match `index.android.bundle` (Android) or `main.jsbundle` (iOS).
+- Next, you can use the Sentry CLI to upload your bundles and source maps:
+  - release name should be set to `${bundleIdentifier}@${version}+${buildNumber}` (iOS) or `${androidPackage}@${version}+${versionCode}` (Android), so for example `com.domain.myapp@1.0.0+1`.
+  - `dist` should be set to the Update ID that `eas update` generated.
+- Command for Android:
+  `node_modules/@sentry/cli/bin/sentry-cli releases \
+files <release name> \
+upload-sourcemaps \
+--dist <Android Update ID> \
+--rewrite \
+dist/bundles/index.android.bundle dist/bundles/android-<hash>.map`
+- Command for iOS:
+  `node_modules/@sentry/cli/bin/sentry-cli releases \
+files <release name> \
+upload-sourcemaps \
+--dist <iOS Update ID> \
+--rewrite \
+dist/bundles/main.jsbundle dist/bundles/ios-<hash>.map`
diff --git a/eas.json b/eas.json
index 32d5b13d9..60c8be378 100644
--- a/eas.json
+++ b/eas.json
@@ -11,29 +11,20 @@
         "simulator": true,
         "resourceClass": "medium"
       },
-      "channel": "development",
-      "env": {
-        "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65"
-      }
+      "channel": "development"
     },
     "preview": {
       "distribution": "internal",
       "ios": {
         "resourceClass": "medium"
       },
-      "channel": "preview",
-      "env": {
-        "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65"
-      }
+      "channel": "preview"
     },
     "production": {
       "ios": {
         "resourceClass": "medium"
       },
-      "channel": "production",
-      "env": {
-        "SENTRY_AUTH_TOKEN": "89c975413cd543fbb683b11bec984fc2163d9a77312c41c0b4480a570f3daa65"
-      }
+      "channel": "production"
     }
   },
   "submit": {
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 7f8d20232..a75a0034f 100644
--- a/jest/test-pds.ts
+++ b/jest/test-pds.ts
@@ -337,6 +337,32 @@ class Mocker {
       ])
       .execute()
   }
+
+  async createMuteList(user: string, name: string): Promise<string> {
+    const res = await this.users[user]?.agent.app.bsky.graph.list.create(
+      {repo: this.users[user]?.did},
+      {
+        purpose: 'app.bsky.graph.defs#modlist',
+        name,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    await this.users[user]?.agent.app.bsky.graph.muteActorList({
+      list: res.uri,
+    })
+    return res.uri
+  }
+
+  async addToMuteList(owner: string, list: string, subject: string) {
+    await this.users[owner]?.agent.app.bsky.graph.listitem.create(
+      {repo: this.users[owner]?.did},
+      {
+        list,
+        subject,
+        createdAt: new Date().toISOString(),
+      },
+    )
+  }
 }
 
 const checkAvailablePort = (port: number) =>
diff --git a/package.json b/package.json
index 6445163a2..c8eb64997 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "android": "expo run:android",
     "ios": "expo run:ios",
     "web": "expo start --web",
-    "build-web": "expo export:web && node ./scripts/post-web-build.js",
+    "build-web": "expo export:web && node ./scripts/post-web-build.js && cp --verbose ./web-build/static/js/*.* ./bskyweb/static/js/",
     "start": "expo start --dev-client",
     "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
     "test": "jest --forceExit --testTimeout=20000 --bail",
@@ -22,7 +22,7 @@
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "0.2.11",
+    "@atproto/api": "0.3.3",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@expo/webpack-config": "^18.0.1",
@@ -73,9 +73,9 @@
     "expo-constants": "~14.2.1",
     "expo-dev-client": "~2.1.1",
     "expo-device": "~5.2.1",
-    "expo-image": "^1.2.1",
+    "expo-image": "^1.2.3",
     "expo-image-manipulator": "^11.1.1",
-    "expo-image-picker": "~14.1.1",
+    "expo-image-picker": "^14.1.1",
     "expo-localization": "~14.1.1",
     "expo-media-library": "~15.2.3",
     "expo-sharing": "~11.2.2",
@@ -141,7 +141,7 @@
     "zod": "^3.20.2"
   },
   "devDependencies": {
-    "@atproto/pds": "^0.1.5",
+    "@atproto/pds": "^0.1.8",
     "@babel/core": "^7.20.0",
     "@babel/preset-env": "^7.20.0",
     "@babel/runtime": "^7.20.0",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 17d80dfdc..45ab439b6 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -33,11 +33,14 @@ import {useStores} from './state'
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
 import {NotificationsScreen} from './view/screens/Notifications'
+import {ModerationScreen} from './view/screens/Moderation'
+import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists'
 import {NotFoundScreen} from './view/screens/NotFound'
 import {SettingsScreen} from './view/screens/Settings'
 import {ProfileScreen} from './view/screens/Profile'
 import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
 import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
+import {ProfileListScreen} from './view/screens/ProfileList'
 import {PostThreadScreen} from './view/screens/PostThread'
 import {PostLikedByScreen} from './view/screens/PostLikedBy'
 import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
@@ -49,12 +52,13 @@ import {TermsOfServiceScreen} from './view/screens/TermsOfService'
 import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
 import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
 import {AppPasswords} from 'view/screens/AppPasswords'
-import {MutedAccounts} from 'view/screens/MutedAccounts'
-import {BlockedAccounts} from 'view/screens/BlockedAccounts'
+import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
+import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
 import {SavedFeeds} from './view/screens/SavedFeeds'
 import {CustomFeed} from './view/screens/CustomFeed'
 import {PinnedFeeds} from 'view/screens/PinnedFeeds'
+import {bskyTitle} from 'lib/strings/headings'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -69,36 +73,125 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
 /**
  * These "common screens" are reused across stacks.
  */
-function commonScreens(Stack: typeof HomeTab) {
+function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
+  const title = (page: string) => bskyTitle(page, unreadCountLabel)
+
   return (
     <>
-      <Stack.Screen name="NotFound" component={NotFoundScreen} />
-      <Stack.Screen name="Settings" component={SettingsScreen} />
-      <Stack.Screen name="Profile" component={ProfileScreen} />
+      <Stack.Screen
+        name="NotFound"
+        component={NotFoundScreen}
+        options={{title: title('Not Found')}}
+      />
+      <Stack.Screen
+        name="Moderation"
+        component={ModerationScreen}
+        options={{title: title('Moderation')}}
+      />
+      <Stack.Screen
+        name="ModerationMuteLists"
+        component={ModerationMuteListsScreen}
+        options={{title: title('Mute Lists')}}
+      />
+      <Stack.Screen
+        name="ModerationMutedAccounts"
+        component={ModerationMutedAccounts}
+        options={{title: title('Muted Accounts')}}
+      />
+      <Stack.Screen
+        name="ModerationBlockedAccounts"
+        component={ModerationBlockedAccounts}
+        options={{title: title('Blocked Accounts')}}
+      />
+      <Stack.Screen
+        name="Settings"
+        component={SettingsScreen}
+        options={{title: title('Settings')}}
+      />
+      <Stack.Screen
+        name="Profile"
+        component={ProfileScreen}
+        options={({route}) => ({title: title(`@${route.params.name}`)})}
+      />
       <Stack.Screen
         name="ProfileFollowers"
         component={ProfileFollowersScreen}
+        options={({route}) => ({
+          title: title(`People following @${route.params.name}`),
+        })}
+      />
+      <Stack.Screen
+        name="ProfileFollows"
+        component={ProfileFollowsScreen}
+        options={({route}) => ({
+          title: title(`People followed by @${route.params.name}`),
+        })}
+      />
+      <Stack.Screen
+        name="ProfileList"
+        component={ProfileListScreen}
+        options={{title: title('Mute List')}}
+      />
+      <Stack.Screen
+        name="PostThread"
+        component={PostThreadScreen}
+        options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
+      />
+      <Stack.Screen
+        name="PostLikedBy"
+        component={PostLikedByScreen}
+        options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
+      />
+      <Stack.Screen
+        name="PostRepostedBy"
+        component={PostRepostedByScreen}
+        options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
+      />
+      <Stack.Screen
+        name="Debug"
+        component={DebugScreen}
+        options={{title: title('Debug')}}
+      />
+      <Stack.Screen
+        name="Log"
+        component={LogScreen}
+        options={{title: title('Log')}}
+      />
+      <Stack.Screen
+        name="Support"
+        component={SupportScreen}
+        options={{title: title('Support')}}
+      />
+      <Stack.Screen
+        name="PrivacyPolicy"
+        component={PrivacyPolicyScreen}
+        options={{title: title('Privacy Policy')}}
+      />
+      <Stack.Screen
+        name="TermsOfService"
+        component={TermsOfServiceScreen}
+        options={{title: title('Terms of Service')}}
       />
-      <Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
-      <Stack.Screen name="PostThread" component={PostThreadScreen} />
-      <Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
-      <Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
-      <Stack.Screen name="Debug" component={DebugScreen} />
-      <Stack.Screen name="Log" component={LogScreen} />
-      <Stack.Screen name="Support" component={SupportScreen} />
-      <Stack.Screen name="PrivacyPolicy" component={PrivacyPolicyScreen} />
-      <Stack.Screen name="TermsOfService" component={TermsOfServiceScreen} />
       <Stack.Screen
         name="CommunityGuidelines"
         component={CommunityGuidelinesScreen}
+        options={{title: title('Community Guidelines')}}
+      />
+      <Stack.Screen
+        name="CopyrightPolicy"
+        component={CopyrightPolicyScreen}
+        options={{title: title('Copyright Policy')}}
+      />
+      <Stack.Screen
+        name="AppPasswords"
+        component={AppPasswords}
+        options={{title: title('App Passwords')}}
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
       <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
       <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
       <Stack.Screen name="CustomFeed" component={CustomFeed} />
-      <Stack.Screen name="MutedAccounts" component={MutedAccounts} />
-      <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
   )
 }
@@ -210,8 +303,10 @@ const MyProfileTabNavigator = observer(() => {
  * The FlatNavigator is used by Web to represent the routes
  * in a single ("flat") stack.
  */
-function FlatNavigator() {
+const FlatNavigator = observer(() => {
   const pal = usePalette('default')
+  const unreadCountLabel = useStores().me.notifications.unreadCountLabel
+  const title = (page: string) => bskyTitle(page, unreadCountLabel)
   return (
     <Flat.Navigator
       screenOptions={{
@@ -221,13 +316,25 @@ function FlatNavigator() {
         animationDuration: 250,
         contentStyle: [pal.view],
       }}>
-      <Flat.Screen name="Home" component={HomeScreen} />
-      <Flat.Screen name="Search" component={SearchScreen} />
-      <Flat.Screen name="Notifications" component={NotificationsScreen} />
-      {commonScreens(Flat as typeof HomeTab)}
+      <Flat.Screen
+        name="Home"
+        component={HomeScreen}
+        options={{title: title('Home')}}
+      />
+      <Flat.Screen
+        name="Search"
+        component={SearchScreen}
+        options={{title: title('Search')}}
+      />
+      <Flat.Screen
+        name="Notifications"
+        component={NotificationsScreen}
+        options={{title: title('Notifications')}}
+      />
+      {commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
     </Flat.Navigator>
   )
-}
+})
 
 /**
  * The RoutesContainer should wrap all components which need access
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index df325b059..6d0d4797b 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -33,42 +33,50 @@ export function TEAM_HANDLES(serviceUrl: string) {
   }
 }
 
+// NOTE
+// this is a temporary list that we periodically update
+// it is used in the search interface if the user doesn't follow anybody
+// -prf
 export const PROD_SUGGESTED_FOLLOWS = [
-  'faithlove.art',
-  'danielkoeth.bsky.social',
   'bsky.app',
   'jay.bsky.team',
   'pfrazee.com',
   'why.bsky.team',
-  'support.bsky.team',
+  'dholms.xyz',
+  'emily.bsky.team',
+  'rose.bsky.team',
   'jack.bsky.social',
-  'earthquake.bsky.social',
+  'faithlove.art',
+  'annaghughes.bsky.social',
+  'astrokatie.com',
+  'whysharksmatter.bsky.social',
   'jamesgunn.bsky.social',
   'seangunn.bsky.social',
   'kumail.bsky.social',
   'craignewmark.bsky.social',
-  'grimes.bsky.social',
   'xychelsea.tv',
+  'catsofyore.bsky.social',
   'mcq.bsky.social',
   'mmasnick.bsky.social',
-  'nitasha.bsky.social',
-  'kenklippenstein.bsky.social',
-  'jaypeters.bsky.social',
-  'miyagawa.bsky.social',
-  'anildash.com',
-  'tiffani.bsky.social',
   'kelseyhightower.com',
   'aliafonzy.bsky.social',
-  'tszzl.bsky.social',
+  'bradfitz.com',
   'danabramov.bsky.social',
   'shinyakato.dev',
   'karpathy.bsky.social',
   'lookitup.baby',
+  'pariss.blacktechpipeline.com',
+  'swiftonsecurity.com',
+  'ericajoy.astrel.la',
+  'b0rk.jvns.ca',
+  'vickiboykis.com',
   'brooke.vibe.camp',
-  'mollywhite.net',
   'amir.blue',
-  'zoink.bsky.social',
   'moskov.bsky.social',
+  'neilhimself.bsky.social',
+  'kylierobison.com',
+  'carnage4life.bsky.social',
+  'lolennui.bsky.social',
 ]
 export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
   handle => `${handle}.staging.bsky.dev`,
diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts
new file mode 100644
index 000000000..c5c7a5ca1
--- /dev/null
+++ b/src/lib/hooks/useSetTitle.ts
@@ -0,0 +1,20 @@
+import {useEffect} from 'react'
+import {useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from 'lib/routes/types'
+import {bskyTitle} from 'lib/strings/headings'
+import {useStores} from 'state/index'
+
+/**
+ * Requires consuming component to be wrapped in `observer`:
+ * https://stackoverflow.com/a/71488009
+ */
+export function useSetTitle(title?: string) {
+  const navigation = useNavigation<NavigationProp>()
+  const {unreadCountLabel} = useStores().me.notifications
+  useEffect(() => {
+    if (title) {
+      navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
+    }
+  }, [title, navigation, unreadCountLabel])
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 960090ad7..0c7b7512a 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -322,6 +322,35 @@ export function MoonIcon({
 
 // Copyright (c) 2020 Refactoring UI Inc.
 // https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
+export function SunIcon({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      width={size || 32}
+      height={size || 32}
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      style={style}>
+      <Path
+        d="M12 3V5.25M18.364 5.63604L16.773 7.22703M21 12H18.75M18.364 18.364L16.773 16.773M12 18.75V21M7.22703 16.773L5.63604 18.364M5.25 12H3M7.22703 7.22703L5.63604 5.63604M15.75 12C15.75 14.0711 14.0711 15.75 12 15.75C9.92893 15.75 8.25 14.0711 8.25 12C8.25 9.92893 9.92893 8.25 12 8.25C14.0711 8.25 15.75 9.92893 15.75 12Z"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </Svg>
+  )
+}
+
+// Copyright (c) 2020 Refactoring UI Inc.
+// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
 export function UserIcon({
   style,
   size,
@@ -828,3 +857,29 @@ export function InfoCircleIcon({
     </Svg>
   )
 }
+
+export function HandIcon({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      width={size}
+      height={size}
+      viewBox="0 0 76 76"
+      stroke="currentColor"
+      strokeWidth={strokeWidth}
+      strokeLinecap="round"
+      fill="none"
+      style={style}>
+      <Path d="M33.5 37V11.5C33.5 8.46243 31.0376 6 28 6V6C24.9624 6 22.5 8.46243 22.5 11.5V48V48C22.5 48.5802 21.8139 48.8874 21.3811 48.501L13.2252 41.2189C10.72 38.9821 6.81945 39.4562 4.92296 42.228L4.77978 42.4372C3.17708 44.7796 3.50863 47.9385 5.56275 49.897L16.0965 59.9409C20.9825 64.5996 26.7533 68.231 33.0675 70.6201V70.6201C38.8234 72.798 45.1766 72.798 50.9325 70.6201L51.9256 70.2444C57.4044 68.1713 61.8038 63.9579 64.1113 58.5735V58.5735C65.6874 54.8962 66.5 50.937 66.5 46.9362V22.5C66.5 19.4624 64.0376 17 61 17V17C57.9624 17 55.5 19.4624 55.5 22.5V36.5" />
+      <Path d="M55.5 37V11.5C55.5 8.46243 53.0376 6 50 6V6C46.9624 6 44.5 8.46243 44.5 11.5V37" />
+      <Path d="M44.5 37V8.5C44.5 5.46243 42.0376 3 39 3V3C35.9624 3 33.5 5.46243 33.5 8.5V37" />
+    </Svg>
+  )
+}
diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts
index baac0ed5a..447b0a99a 100644
--- a/src/lib/labeling/helpers.ts
+++ b/src/lib/labeling/helpers.ts
@@ -1,5 +1,6 @@
 import {
   AppBskyActorDefs,
+  AppBskyGraphDefs,
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
   AppBskyEmbedImages,
@@ -16,6 +17,7 @@ import {
   Label,
   LabelValGroup,
   ModerationBehaviorCode,
+  ModerationBehavior,
   PostModeration,
   ProfileModeration,
   PostLabelInfo,
@@ -127,11 +129,15 @@ export function getPostModeration(
 
   // muting
   if (postInfo.isMuted) {
+    let msg = 'Post from an account you muted.'
+    if (postInfo.mutedByList) {
+      msg = `Muted by ${postInfo.mutedByList.name}`
+    }
     return {
       avatar,
-      list: hide('Post from an account you muted.'),
-      thread: warn('Post from an account you muted.'),
-      view: warn('Post from an account you muted.'),
+      list: isMute(hide(msg)),
+      thread: isMute(warn(msg)),
+      view: isMute(warn(msg)),
     }
   }
 
@@ -273,6 +279,7 @@ export function getProfileViewBasicLabelInfo(
     profileLabels: filterProfileLabels(profile.labels),
     isMuted: profile.viewer?.muted || false,
     isBlocking: !!profile.viewer?.blocking || false,
+    isBlockedBy: !!profile.viewer?.blockedBy || false,
   }
 }
 
@@ -302,6 +309,21 @@ export function getEmbedMuted(embed?: Embed): boolean {
   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
@@ -401,6 +423,11 @@ function warnContent(reason: string) {
   }
 }
 
+function isMute(behavior: ModerationBehavior): ModerationBehavior {
+  behavior.isMute = true
+  return behavior
+}
+
 function warnImages(reason: string) {
   return {
     behavior: ModerationBehaviorCode.WarnImages,
diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts
index 078043076..1ee058024 100644
--- a/src/lib/labeling/types.ts
+++ b/src/lib/labeling/types.ts
@@ -1,4 +1,4 @@
-import {ComAtprotoLabelDefs} from '@atproto/api'
+import {ComAtprotoLabelDefs, AppBskyGraphDefs} from '@atproto/api'
 import {LabelPreferencesModel} from 'state/models/ui/preferences'
 
 export type Label = ComAtprotoLabelDefs.Label
@@ -22,6 +22,7 @@ export interface PostLabelInfo {
   accountLabels: Label[]
   profileLabels: Label[]
   isMuted: boolean
+  mutedByList?: AppBskyGraphDefs.ListViewBasic
   isBlocking: boolean
   isBlockedBy: boolean
 }
@@ -44,6 +45,7 @@ export enum ModerationBehaviorCode {
 
 export interface ModerationBehavior {
   behavior: ModerationBehaviorCode
+  isMute?: boolean
   noOverride?: boolean
   reason?: string
 }
diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx
index af4a3e4d3..7c6dfcc44 100644
--- a/src/lib/media/picker.tsx
+++ b/src/lib/media/picker.tsx
@@ -1,12 +1,16 @@
 import {
-  openPicker as openPickerFn,
   openCamera as openCameraFn,
   openCropper as openCropperFn,
-  ImageOrVideo,
+  Image as RNImage,
 } from 'react-native-image-crop-picker'
 import {RootStoreModel} from 'state/index'
-import {PickerOpts, CameraOpts, CropperOptions} from './types'
-import {Image as RNImage} from 'react-native-image-crop-picker'
+import {CameraOpts, CropperOptions} from './types'
+import {
+  ImagePickerOptions,
+  launchImageLibraryAsync,
+  MediaTypeOptions,
+} from 'expo-image-picker'
+import {getDataUriSize} from './util'
 
 /**
  * NOTE
@@ -19,27 +23,22 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 
 export async function openPicker(
   _store: RootStoreModel,
-  opts?: PickerOpts,
-): Promise<RNImage[]> {
-  const items = await openPickerFn({
-    mediaType: 'photo', // TODO: eventually add other media types
-    multiple: opts?.multiple,
-    maxFiles: opts?.maxFiles,
-    forceJpg: true, // ios only
-    compressImageQuality: 0.8,
+  opts?: ImagePickerOptions,
+) {
+  const response = await launchImageLibraryAsync({
+    exif: false,
+    mediaTypes: MediaTypeOptions.Images,
+    quality: 1,
+    ...opts,
   })
 
-  const toMedia = (item: ImageOrVideo) => ({
-    path: item.path,
-    mime: item.mime,
-    size: item.size,
-    width: item.width,
-    height: item.height,
-  })
-  if (Array.isArray(items)) {
-    return items.map(toMedia)
-  }
-  return [toMedia(items)]
+  return (response.assets ?? []).map(image => ({
+    mime: 'image/jpeg',
+    height: image.height,
+    width: image.width,
+    path: image.uri,
+    size: getDataUriSize(image.uri),
+  }))
 }
 
 export async function openCamera(
@@ -55,6 +54,7 @@ export async function openCamera(
     forceJpg: true, // ios only
     compressImageQuality: 0.8,
   })
+
   return {
     path: item.path,
     mime: item.mime,
@@ -67,11 +67,10 @@ export async function openCamera(
 export async function openCropper(
   _store: RootStoreModel,
   opts: CropperOptions,
-): Promise<RNImage> {
+) {
   const item = await openCropperFn({
     ...opts,
     forceJpg: true, // ios only
-    compressImageQuality: 0.8,
   })
 
   return {
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index b1dcbb999..8b96aaad7 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -5,13 +5,19 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 export type CommonNavigatorParams = {
   NotFound: undefined
+  Moderation: undefined
+  ModerationMuteLists: undefined
+  ModerationMutedAccounts: undefined
+  ModerationBlockedAccounts: undefined
   Settings: undefined
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
   ProfileFollows: {name: string}
+  ProfileList: {name: string; rkey: string}
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
+  CustomFeed: {name: string; rkey: string; displayName?: string}
   Debug: undefined
   Log: undefined
   Support: undefined
@@ -22,9 +28,6 @@ export type CommonNavigatorParams = {
   AppPasswords: undefined
   SavedFeeds: undefined
   PinnedFeeds: undefined
-  CustomFeed: {name: string; rkey: string; displayName?: string}
-  MutedAccounts: undefined
-  BlockedAccounts: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 555151b55..b98153732 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -10,3 +10,18 @@ export function sanitizeDisplayName(str: string): string {
   }
   return ''
 }
+
+export function combinedDisplayName({
+  handle,
+  displayName,
+}: {
+  handle?: string
+  displayName?: string
+}): string {
+  if (!handle) {
+    return ''
+  }
+  return displayName
+    ? `${sanitizeDisplayName(displayName)} (@${handle})`
+    : `@${handle}`
+}
diff --git a/src/lib/strings/headings.ts b/src/lib/strings/headings.ts
new file mode 100644
index 000000000..a88a69645
--- /dev/null
+++ b/src/lib/strings/headings.ts
@@ -0,0 +1,4 @@
+export function bskyTitle(page: string, unreadCountLabel?: string) {
+  const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
+  return `${unreadPrefix}${page} - Bluesky`
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 549587f74..a5412920e 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -94,6 +94,15 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
   return url
 }
 
+export function listUriToHref(url: string): string {
+  try {
+    const {hostname, rkey} = new AtUri(url)
+    return `/profile/${hostname}/lists/${rkey}`
+  } catch {
+    return ''
+  }
+}
+
 export function getYoutubeVideoId(link: string): string | undefined {
   let url
   try {
diff --git a/src/routes.ts b/src/routes.ts
index 6998226c0..7501e7abf 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -5,20 +5,23 @@ export const router = new Router({
   Search: '/search',
   Notifications: '/notifications',
   Settings: '/settings',
+  Moderation: '/moderation',
+  ModerationMuteLists: '/moderation/mute-lists',
+  ModerationMutedAccounts: '/moderation/muted-accounts',
+  ModerationBlockedAccounts: '/moderation/blocked-accounts',
   Profile: '/profile/:name',
   ProfileFollowers: '/profile/:name/followers',
   ProfileFollows: '/profile/:name/follows',
+  ProfileList: '/profile/:name/lists/:rkey',
   PostThread: '/profile/:name/post/:rkey',
   PostLikedBy: '/profile/:name/post/:rkey/liked-by',
   PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
+  CustomFeed: '/profile/:name/feed/:rkey',
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   SavedFeeds: '/settings/saved-feeds',
   PinnedFeeds: '/settings/pinned-feeds',
-  CustomFeed: '/profile/:name/feed/:rkey',
-  MutedAccounts: '/settings/muted-accounts',
-  BlockedAccounts: '/settings/blocked-accounts',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
   TermsOfService: '/support/tos',
diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts
new file mode 100644
index 000000000..b4af4472b
--- /dev/null
+++ b/src/state/models/content/list-membership.ts
@@ -0,0 +1,112 @@
+import {makeAutoObservable} from 'mobx'
+import {AtUri, AppBskyGraphListitem} from '@atproto/api'
+import {runInAction} from 'mobx'
+import {RootStoreModel} from '../root-store'
+
+const PAGE_SIZE = 100
+interface Membership {
+  uri: string
+  value: AppBskyGraphListitem.Record
+}
+
+export class ListMembershipModel {
+  // data
+  memberships: Membership[] = []
+
+  constructor(public rootStore: RootStoreModel, public subject: string) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  // public api
+  // =
+
+  async fetch() {
+    // NOTE
+    // this approach to determining list membership is too inefficient to work at any scale
+    // it needs to be replaced with server side list membership queries
+    // -prf
+    let cursor
+    let records = []
+    for (let i = 0; i < 100; i++) {
+      const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
+        repo: this.rootStore.me.did,
+        cursor,
+        limit: PAGE_SIZE,
+      })
+      records = records.concat(
+        res.records.filter(record => record.value.subject === this.subject),
+      )
+      cursor = res.cursor
+      if (!cursor) {
+        break
+      }
+    }
+    runInAction(() => {
+      this.memberships = records
+    })
+  }
+
+  getMembership(listUri: string) {
+    return this.memberships.find(m => m.value.list === listUri)
+  }
+
+  isMember(listUri: string) {
+    return !!this.getMembership(listUri)
+  }
+
+  async add(listUri: string) {
+    if (this.isMember(listUri)) {
+      return
+    }
+    const res = await this.rootStore.agent.app.bsky.graph.listitem.create(
+      {
+        repo: this.rootStore.me.did,
+      },
+      {
+        subject: this.subject,
+        list: listUri,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    const {rkey} = new AtUri(res.uri)
+    const record = await this.rootStore.agent.app.bsky.graph.listitem.get({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+    runInAction(() => {
+      this.memberships = this.memberships.concat([record])
+    })
+  }
+
+  async remove(listUri: string) {
+    const membership = this.getMembership(listUri)
+    if (!membership) {
+      return
+    }
+    const {rkey} = new AtUri(membership.uri)
+    await this.rootStore.agent.app.bsky.graph.listitem.delete({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+    runInAction(() => {
+      this.memberships = this.memberships.filter(m => m.value.list !== listUri)
+    })
+  }
+
+  async updateTo(uris: string) {
+    for (const uri of uris) {
+      await this.add(uri)
+    }
+    for (const membership of this.memberships) {
+      if (!uris.includes(membership.value.list)) {
+        await this.remove(membership.value.list)
+      }
+    }
+  }
+}
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
new file mode 100644
index 000000000..673ee9430
--- /dev/null
+++ b/src/state/models/content/list.ts
@@ -0,0 +1,257 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AtUri,
+  AppBskyGraphGetList as GetList,
+  AppBskyGraphDefs as GraphDefs,
+  AppBskyGraphList,
+} from '@atproto/api'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {RootStoreModel} from '../root-store'
+import * as apilib from 'lib/api/index'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class ListModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  loadMoreError = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  list: GraphDefs.ListView | null = null
+  items: GraphDefs.ListItemView[] = []
+
+  static async createModList(
+    rootStore: RootStoreModel,
+    {
+      name,
+      description,
+      avatar,
+    }: {name: string; description: string; avatar: RNImage | undefined},
+  ) {
+    const record: AppBskyGraphList.Record = {
+      purpose: 'app.bsky.graph.defs#modlist',
+      name,
+      description,
+      avatar: undefined,
+      createdAt: new Date().toISOString(),
+    }
+    if (avatar) {
+      const blobRes = await apilib.uploadBlob(
+        rootStore,
+        avatar.path,
+        avatar.mime,
+      )
+      record.avatar = blobRes.data.blob
+    }
+    const res = await rootStore.agent.app.bsky.graph.list.create(
+      {
+        repo: rootStore.me.did,
+      },
+      record,
+    )
+    await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri})
+    return res
+  }
+
+  constructor(public rootStore: RootStoreModel, public uri: string) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.items.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get isOwner() {
+    return this.list?.creator.did === this.rootStore.me.did
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.graph.getList({
+        list: this.uri,
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
+    }
+  })
+
+  async updateMetadata({
+    name,
+    description,
+    avatar,
+  }: {
+    name: string
+    description: string
+    avatar: RNImage | null | undefined
+  }) {
+    if (!this.isOwner) {
+      throw new Error('Cannot edit this list')
+    }
+
+    // get the current record
+    const {rkey} = new AtUri(this.uri)
+    const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+
+    // update the fields
+    record.name = name
+    record.description = description
+    if (avatar) {
+      const blobRes = await apilib.uploadBlob(
+        this.rootStore,
+        avatar.path,
+        avatar.mime,
+      )
+      record.avatar = blobRes.data.blob
+    } else if (avatar === null) {
+      record.avatar = undefined
+    }
+    return await this.rootStore.agent.com.atproto.repo.putRecord({
+      repo: this.rootStore.me.did,
+      collection: 'app.bsky.graph.list',
+      rkey,
+      record,
+    })
+  }
+
+  async delete() {
+    // fetch all the listitem records that belong to this list
+    let cursor
+    let records = []
+    for (let i = 0; i < 100; i++) {
+      const res = await this.rootStore.agent.app.bsky.graph.listitem.list({
+        repo: this.rootStore.me.did,
+        cursor,
+        limit: PAGE_SIZE,
+      })
+      records = records.concat(
+        res.records.filter(record => record.value.list === this.uri),
+      )
+      cursor = res.cursor
+      if (!cursor) {
+        break
+      }
+    }
+
+    // batch delete the list and listitem records
+    const createDel = (uri: string) => {
+      const urip = new AtUri(uri)
+      return {
+        $type: 'com.atproto.repo.applyWrites#delete',
+        collection: urip.collection,
+        rkey: urip.rkey,
+      }
+    }
+    await this.rootStore.agent.com.atproto.repo.applyWrites({
+      repo: this.rootStore.me.did,
+      writes: [createDel(this.uri)].concat(
+        records.map(record => createDel(record.uri)),
+      ),
+    })
+  }
+
+  async subscribe() {
+    await this.rootStore.agent.app.bsky.graph.muteActorList({
+      list: this.list.uri,
+    })
+    await this.refresh()
+  }
+
+  async unsubscribe() {
+    await this.rootStore.agent.app.bsky.graph.unmuteActorList({
+      list: this.list.uri,
+    })
+    await this.refresh()
+  }
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any, loadMoreErr?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    this.loadMoreError = cleanError(loadMoreErr)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user items', err)
+    }
+    if (loadMoreErr) {
+      this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetList.Response) {
+    this.items = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetList.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    this.list = res.data.list
+    this.items = this.items.concat(
+      res.data.items.map(item => ({...item, _reactKey: item.subject})),
+    )
+  }
+}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index a0f75493a..74a75d803 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -14,6 +14,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
   getEmbedMuted,
+  getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
   filterAccountLabels,
@@ -70,6 +71,9 @@ export class PostThreadItemModel {
         this.post.author.viewer?.muted ||
         getEmbedMuted(this.post.embed) ||
         false,
+      mutedByList:
+        this.post.author.viewer?.mutedByList ||
+        getEmbedMutedByList(this.post.embed),
       isBlocking:
         !!this.post.author.viewer?.blocking ||
         getEmbedBlocking(this.post.embed) ||
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index dddf488a3..9d8378f79 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AtUri,
   ComAtprotoLabelDefs,
+  AppBskyGraphDefs,
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
   RichText,
@@ -18,10 +19,9 @@ import {
   filterProfileLabels,
 } from 'lib/labeling/helpers'
 
-export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
-
 export class ProfileViewerModel {
   muted?: boolean
+  mutedByList?: AppBskyGraphDefs.ListViewBasic
   following?: string
   followedBy?: string
   blockedBy?: boolean
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 3ffd10b99..73424f03e 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -111,6 +111,7 @@ export class NotificationsFeedItemModel {
         addedInfo?.profileLabels || [],
       ),
       isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
+      mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
       isBlocking:
         !!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
       isBlockedBy:
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 7adc1cb1c..dfd92b35c 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -25,6 +25,7 @@ import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
   getEmbedMuted,
+  getEmbedMutedByList,
   getEmbedBlocking,
   getEmbedBlockedBy,
   getPostModeration,
@@ -106,6 +107,9 @@ export class PostsFeedItemModel {
         this.post.author.viewer?.muted ||
         getEmbedMuted(this.post.embed) ||
         false,
+      mutedByList:
+        this.post.author.viewer?.mutedByList ||
+        getEmbedMutedByList(this.post.embed),
       isBlocking:
         !!this.post.author.viewer?.blocking ||
         getEmbedBlocking(this.post.embed) ||
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
new file mode 100644
index 000000000..309ab0e03
--- /dev/null
+++ b/src/state/models/lists/lists-list.ts
@@ -0,0 +1,214 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AppBskyGraphGetLists as GetLists,
+  AppBskyGraphGetListMutes as GetListMutes,
+  AppBskyGraphDefs as GraphDefs,
+} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class ListsListModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  loadMoreError = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  lists: GraphDefs.ListView[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public source: 'my-modlists' | string,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.lists.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      let res
+      if (this.source === 'my-modlists') {
+        res = {
+          success: true,
+          headers: {},
+          data: {
+            subject: undefined,
+            lists: [],
+          },
+        }
+        const [res1, res2] = await Promise.all([
+          fetchAllUserLists(this.rootStore, this.rootStore.me.did),
+          fetchAllMyMuteLists(this.rootStore),
+        ])
+        for (let list of res1.data.lists) {
+          if (list.purpose === 'app.bsky.graph.defs#modlist') {
+            res.data.lists.push(list)
+          }
+        }
+        for (let list of res2.data.lists) {
+          if (
+            list.purpose === 'app.bsky.graph.defs#modlist' &&
+            !res.data.lists.find(l => l.uri === list.uri)
+          ) {
+            res.data.lists.push(list)
+          }
+        }
+      } else {
+        res = await this.rootStore.agent.app.bsky.graph.getLists({
+          actor: this.source,
+          limit: PAGE_SIZE,
+          cursor: replace ? undefined : this.loadMoreCursor,
+        })
+      }
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(replace ? e : undefined, !replace ? e : undefined)
+    }
+  })
+
+  /**
+   * Attempt to load more again after a failure
+   */
+  async retryLoadMore() {
+    this.loadMoreError = ''
+    this.hasMore = true
+    return this.loadMore()
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any, loadMoreErr?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    this.loadMoreError = cleanError(loadMoreErr)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user lists', err)
+    }
+    if (loadMoreErr) {
+      this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetLists.Response | GetListMutes.Response) {
+    this.lists = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetLists.Response | GetListMutes.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    this.lists = this.lists.concat(
+      res.data.lists.map(list => ({...list, _reactKey: list.uri})),
+    )
+  }
+}
+
+async function fetchAllUserLists(
+  store: RootStoreModel,
+  did: string,
+): Promise<GetLists.Response> {
+  let acc: GetLists.Response = {
+    success: true,
+    headers: {},
+    data: {
+      subject: undefined,
+      lists: [],
+    },
+  }
+
+  let cursor
+  for (let i = 0; i < 100; i++) {
+    const res = await store.agent.app.bsky.graph.getLists({
+      actor: did,
+      cursor,
+      limit: 50,
+    })
+    cursor = res.data.cursor
+    acc.data.lists = acc.data.lists.concat(res.data.lists)
+    if (!cursor) {
+      break
+    }
+  }
+
+  return acc
+}
+
+async function fetchAllMyMuteLists(
+  store: RootStoreModel,
+): Promise<GetListMutes.Response> {
+  let acc: GetListMutes.Response = {
+    success: true,
+    headers: {},
+    data: {
+      subject: undefined,
+      lists: [],
+    },
+  }
+
+  let cursor
+  for (let i = 0; i < 100; i++) {
+    const res = await store.agent.app.bsky.graph.getListMutes({
+      cursor,
+      limit: 50,
+    })
+    cursor = res.data.cursor
+    acc.data.lists = acc.data.lists.concat(res.data.lists)
+    if (!cursor) {
+      break
+    }
+  }
+
+  return acc
+}
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 86bf8a314..67f8d2ea1 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -52,16 +52,14 @@ export class GalleryModel {
   }
 
   async edit(image: ImageModel) {
-    if (!isNative) {
+    if (isNative) {
+      this.crop(image)
+    } else {
       this.rootStore.shell.openModal({
         name: 'edit-image',
         image,
         gallery: this,
       })
-
-      return
-    } else {
-      this.crop(image)
     }
   }
 
@@ -104,10 +102,14 @@ export class GalleryModel {
 
   async pick() {
     const images = await openPicker(this.rootStore, {
-      multiple: true,
-      maxFiles: 4 - this.images.length,
+      selectionLimit: 4 - this.size,
+      allowsMultipleSelection: true,
     })
 
-    await Promise.all(images.map(image => this.add(image)))
+    return await Promise.all(
+      images.map(image => {
+        this.add(image)
+      }),
+    )
   }
 }
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index ff464a5a9..ec93bf5b6 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -13,12 +13,12 @@ import {compressAndResizeImageForPost} from 'lib/media/manip'
 // Cases to consider: ExternalEmbed
 
 export interface ImageManipulationAttributes {
+  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
   rotate?: number
   scale?: number
   position?: Position
   flipHorizontal?: boolean
   flipVertical?: boolean
-  aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
 }
 
 export class ImageModel implements RNImage {
@@ -34,14 +34,14 @@ export class ImageModel implements RNImage {
   scaledHeight: number = POST_IMG_MAX.height
 
   // Web manipulation
-  aspectRatio?: ImageManipulationAttributes['aspectRatio']
-  position?: Position = undefined
-  prev?: RNImage = undefined
-  rotation?: number = 0
-  scale?: number = 1
-  flipHorizontal?: boolean = false
-  flipVertical?: boolean = false
-
+  prev?: RNImage
+  attributes: ImageManipulationAttributes = {
+    aspectRatio: '1:1',
+    scale: 1,
+    flipHorizontal: false,
+    flipVertical: false,
+    rotate: 0,
+  }
   prevAttributes: ImageManipulationAttributes = {}
 
   constructor(public rootStore: RootStoreModel, image: RNImage) {
@@ -65,6 +65,25 @@ export class ImageModel implements RNImage {
   //     : MAX_IMAGE_SIZE_IN_BYTES / this.size
   // }
 
+  setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
+    this.attributes.aspectRatio = aspectRatio
+  }
+
+  setRotate(degrees: number) {
+    this.attributes.rotate = degrees
+    this.manipulate({})
+  }
+
+  flipVertical() {
+    this.attributes.flipVertical = !this.attributes.flipVertical
+    this.manipulate({})
+  }
+
+  flipHorizontal() {
+    this.attributes.flipHorizontal = !this.attributes.flipHorizontal
+    this.manipulate({})
+  }
+
   get ratioMultipliers() {
     return {
       '4:3': 4 / 3,
@@ -116,7 +135,7 @@ export class ImageModel implements RNImage {
   // Only for mobile
   async crop() {
     try {
-      const cropped = await openCropper(this.rootStore, {
+      const cropped = await openCropper({
         mediaType: 'photo',
         path: this.path,
         freeStyleCropEnabled: true,
@@ -162,33 +181,19 @@ export class ImageModel implements RNImage {
       crop?: ActionCrop['crop']
     } & ImageManipulationAttributes,
   ) {
-    const {aspectRatio, crop, flipHorizontal, flipVertical, rotate, scale} =
-      attributes
+    const {aspectRatio, crop, position, scale} = attributes
     const modifiers = []
 
-    if (flipHorizontal !== undefined) {
-      this.flipHorizontal = flipHorizontal
-    }
-
-    if (flipVertical !== undefined) {
-      this.flipVertical = flipVertical
-    }
-
-    if (this.flipHorizontal) {
+    if (this.attributes.flipHorizontal) {
       modifiers.push({flip: FlipType.Horizontal})
     }
 
-    if (this.flipVertical) {
+    if (this.attributes.flipVertical) {
       modifiers.push({flip: FlipType.Vertical})
     }
 
-    // TODO: Fix rotation -- currently not functional
-    if (rotate !== undefined) {
-      this.rotation = rotate
-    }
-
-    if (this.rotation !== undefined) {
-      modifiers.push({rotate: this.rotation})
+    if (this.attributes.rotate !== undefined) {
+      modifiers.push({rotate: this.attributes.rotate})
     }
 
     if (crop !== undefined) {
@@ -203,18 +208,21 @@ export class ImageModel implements RNImage {
     }
 
     if (scale !== undefined) {
-      this.scale = scale
+      this.attributes.scale = scale
+    }
+
+    if (position !== undefined) {
+      this.attributes.position = position
     }
 
     if (aspectRatio !== undefined) {
-      this.aspectRatio = aspectRatio
+      this.attributes.aspectRatio = aspectRatio
     }
 
-    const ratioMultiplier = this.ratioMultipliers[this.aspectRatio ?? '1:1']
+    const ratioMultiplier =
+      this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
 
-    // TODO: Ollie - should support up to 2000 but smaller images that scale
-    // up need an updated compression factor calculation. Use 1000 for now.
-    const MAX_SIDE = 1000
+    const MAX_SIDE = 2000
 
     const result = await ImageManipulator.manipulateAsync(
       this.path,
@@ -223,7 +231,7 @@ export class ImageModel implements RNImage {
         {resize: ratioMultiplier > 1 ? {width: MAX_SIDE} : {height: MAX_SIDE}},
       ],
       {
-        compress: 0.7, // TODO: revisit compression calculation
+        compress: 0.9,
         format: SaveFormat.JPEG,
       },
     )
@@ -238,16 +246,12 @@ export class ImageModel implements RNImage {
     })
   }
 
+  resetCompressed() {
+    this.manipulate({})
+  }
+
   previous() {
     this.compressed = this.prev
-
-    const {flipHorizontal, flipVertical, rotate, position, scale} =
-      this.prevAttributes
-
-    this.scale = scale
-    this.rotation = rotate
-    this.flipHorizontal = flipHorizontal
-    this.flipVertical = flipVertical
-    this.position = position
+    this.attributes = this.prevAttributes
   }
 }
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 8cd23efcd..f2a352a79 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -37,7 +37,7 @@ export class RootStoreModel {
   log = new LogModel()
   session = new SessionModel(this)
   shell = new ShellUiModel(this)
-  preferences = new PreferencesModel()
+  preferences = new PreferencesModel(this)
   me = new MeModel(this)
   invitedUsers = new InvitedUsers(this)
   profiles = new ProfilesCache(this)
@@ -126,6 +126,7 @@ export class RootStoreModel {
     this.log.debug('RootStoreModel:handleSessionChange')
     this.agent = agent
     this.me.clear()
+    /* dont await */ this.preferences.sync()
     await this.me.load()
     if (!hadSession) {
       resetNavigation()
@@ -161,6 +162,7 @@ export class RootStoreModel {
     }
     try {
       await this.me.updateIfNeeded()
+      await this.preferences.sync()
     } catch (e: any) {
       this.log.error('Failed to fetch latest state', e)
     }
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index fcd33af8e..1471420fc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,7 +1,8 @@
-import {makeAutoObservable} from 'mobx'
+import {makeAutoObservable, runInAction} from 'mobx'
 import {getLocales} from 'expo-localization'
 import {isObj, hasProp} from 'lib/type-guards'
-import {ComAtprotoLabelDefs} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
 import {LabelValGroup} from 'lib/labeling/types'
 import {getLabelValueGroup} from 'lib/labeling/helpers'
 import {
@@ -15,6 +16,15 @@ import {isIOS} from 'platform/detection'
 const deviceLocales = getLocales()
 
 export type LabelPreference = 'show' | 'warn' | 'hide'
+const LABEL_GROUPS = [
+  'nsfw',
+  'nudity',
+  'suggestive',
+  'gore',
+  'hate',
+  'spam',
+  'impersonation',
+]
 
 export class LabelPreferencesModel {
   nsfw: LabelPreference = 'hide'
@@ -36,7 +46,7 @@ export class PreferencesModel {
     deviceLocales?.map?.(locale => locale.languageCode) || []
   contentLabels = new LabelPreferencesModel()
 
-  constructor() {
+  constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(this, {}, {autoBind: true})
   }
 
@@ -65,6 +75,35 @@ export class PreferencesModel {
     }
   }
 
+  async sync() {
+    const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+    runInAction(() => {
+      for (const pref of res.data.preferences) {
+        if (
+          AppBskyActorDefs.isAdultContentPref(pref) &&
+          AppBskyActorDefs.validateAdultContentPref(pref).success
+        ) {
+          this.adultContentEnabled = pref.enabled
+        } else if (
+          AppBskyActorDefs.isContentLabelPref(pref) &&
+          AppBskyActorDefs.validateAdultContentPref(pref).success
+        ) {
+          if (LABEL_GROUPS.includes(pref.label)) {
+            this.contentLabels[pref.label] = pref.visibility
+          }
+        }
+      }
+    })
+  }
+
+  async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) {
+    const res = await this.rootStore.agent.app.bsky.actor.getPreferences({})
+    cb(res.data.preferences)
+    await this.rootStore.agent.app.bsky.actor.putPreferences({
+      preferences: res.data.preferences,
+    })
+  }
+
   hasContentLanguage(code2: string) {
     return this.contentLanguages.includes(code2)
   }
@@ -79,11 +118,48 @@ export class PreferencesModel {
     }
   }
 
-  setContentLabelPref(
+  async setContentLabelPref(
     key: keyof LabelPreferencesModel,
     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,
+        })
+      }
+    })
+  }
+
+  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,
+        })
+      }
+    })
   }
 
   getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): {
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 855955d12..4f604bfc0 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -1,20 +1,23 @@
 import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
 import {ActorFeedsModel} from '../feeds/algo/actor'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
   CustomAlgorithms = 'Algos',
+  Lists = 'Lists',
 }
 
 const USER_SELECTOR_ITEMS = [
   Sections.Posts,
   Sections.PostsWithReplies,
   Sections.CustomAlgorithms,
+  Sections.Lists,
 ]
 
 export interface ProfileUiParams {
@@ -30,6 +33,7 @@ export class ProfileUiModel {
   profile: ProfileModel
   feed: PostsFeedModel
   algos: ActorFeedsModel
+  lists: ListsListModel
 
   // ui state
   selectedViewIndex = 0
@@ -52,14 +56,17 @@ export class ProfileUiModel {
       limit: 10,
     })
     this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
+    this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel | ActorFeedsModel {
+  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
     ) {
       return this.feed
+    } else if (this.selectedView === Sections.Lists) {
+      return this.lists
     }
     if (this.selectedView === Sections.CustomAlgorithms) {
       return this.algos
@@ -121,6 +128,12 @@ export class ProfileUiModel {
         } else if (this.feed.isEmpty) {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
+      } else if (this.selectedView === Sections.Lists) {
+        if (this.lists.hasContent) {
+          arr = this.lists.lists
+        } else if (this.lists.isEmpty) {
+          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
+        }
       } else {
         // fallback, add empty item, to show empty message
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
@@ -135,6 +148,8 @@ export class ProfileUiModel {
       this.selectedView === Sections.PostsWithReplies
     ) {
       return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
+    } else if (this.selectedView === Sections.Lists) {
+      return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading
     }
     return false
   }
@@ -155,6 +170,11 @@ export class ProfileUiModel {
         .setup()
         .catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
     ])
+    // HACK: need to use the DID as a param, not the username -prf
+    this.lists.source = this.profile.did
+    this.lists
+      .loadMore()
+      .catch(err => this.rootStore.log.error('Failed to fetch lists', err))
   }
 
   async update() {
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 67f8e16d4..9b9a176be 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -5,6 +5,7 @@ import {ProfileModel} from '../content/profile'
 import {isObj, hasProp} from 'lib/type-guards'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '../media/image'
+import {ListModel} from '../content/list'
 import {GalleryModel} from '../media/gallery'
 
 export interface ConfirmModal {
@@ -38,6 +39,19 @@ export interface ReportAccountModal {
   did: string
 }
 
+export interface CreateOrEditMuteListModal {
+  name: 'create-or-edit-mute-list'
+  list?: ListModel
+  onSave?: (uri: string) => void
+}
+
+export interface ListAddRemoveUserModal {
+  name: 'list-add-remove-user'
+  subject: string
+  displayName: string
+  onUpdate?: () => void
+}
+
 export interface EditImageModal {
   name: 'edit-image'
   image: ImageModel
@@ -102,9 +116,11 @@ export type Modal =
   | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
 
-  // Reporting
+  // Moderation
   | ReportAccountModal
   | ReportPostModal
+  | CreateMuteListModal
+  | ListAddRemoveUserModal
 
   // Posts
   | AltTextImageModal
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index accd96803..436824952 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -61,7 +61,6 @@ export const Gallery = observer(function ({gallery}: Props) {
     borderRadius: 5,
     paddingHorizontal: 10,
     position: 'absolute' as const,
-    width: 46,
     zIndex: 1,
     ...(isOverflow
       ? {
@@ -112,11 +111,11 @@ export const Gallery = observer(function ({gallery}: Props) {
               testID="altTextButton"
               accessibilityRole="button"
               accessibilityLabel="Add alt text"
-              accessibilityHint="Opens modal for inputting image alt text"
+              accessibilityHint=""
               onPress={() => {
                 handleAddImageAltText(image)
               }}
-              style={[styles.imageControl, imageControlLabelStyle]}>
+              style={imageControlLabelStyle}>
               <Text style={styles.imageControlTextContent}>ALT</Text>
             </TouchableOpacity>
             <View style={imageControlsSubgroupStyle}>
@@ -187,9 +186,14 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
   },
   imageControlTextContent: {
+    borderRadius: 6,
     color: 'white',
     fontSize: 12,
     fontWeight: 'bold',
     letterSpacing: 1,
+    backgroundColor: 'rgba(0, 0, 0, 0.75)',
+    borderWidth: 0.5,
+    paddingHorizontal: 10,
+    paddingVertical: 3,
   },
 })
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 475ec119b..87820b97b 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -4,7 +4,7 @@ import React, {
   useImperativeHandle,
   useState,
 } from 'react'
-import {StyleSheet, View} from 'react-native'
+import {Pressable, StyleSheet, View} from 'react-native'
 import {ReactRenderer} from '@tiptap/react'
 import tippy, {Instance as TippyInstance} from 'tippy.js'
 import {
@@ -158,7 +158,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
               const isSelected = selectedIndex === index
 
               return (
-                <View
+                <Pressable
                   key={item.handle}
                   style={[
                     isSelected ? pal.viewLight : undefined,
@@ -169,7 +169,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
                       : index === items.length - 1
                       ? styles.lastMention
                       : undefined,
-                  ]}>
+                  ]}
+                  onPress={() => {
+                    selectItem(index)
+                  }}
+                  accessibilityRole="button">
                   <View style={styles.avatarAndDisplayName}>
                     <UserAvatar avatar={item.avatar ?? null} size={26} />
                     <Text style={pal.text} numberOfLines={1}>
@@ -179,7 +183,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
                   <Text type="xs" style={pal.textLight} numberOfLines={1}>
                     @{item.handle}
                   </Text>
-                </View>
+                </Pressable>
               )
             })
           ) : (
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index eff9af2d2..d389279b1 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -21,6 +21,9 @@ interface Img {
 
 export const Lightbox = observer(function Lightbox() {
   const store = useStores()
+
+  const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell])
+
   if (!store.shell.isLightboxActive) {
     return null
   }
@@ -29,8 +32,6 @@ export const Lightbox = observer(function Lightbox() {
   const initialIndex =
     activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0
 
-  const onClose = () => store.shell.closeLightbox()
-
   let imgs: Img[] | undefined
   if (activeLightbox instanceof models.ProfileImageLightbox) {
     const opts = activeLightbox
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
new file mode 100644
index 000000000..7cbdaaf64
--- /dev/null
+++ b/src/view/com/lists/ListCard.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
+import {Link} from '../util/Link'
+import {Text} from '../util/text/Text'
+import {RichText as RichTextCom} from '../util/text/RichText'
+import {UserAvatar} from '../util/UserAvatar'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+export const ListCard = ({
+  testID,
+  list,
+  noBg,
+  noBorder,
+  renderButton,
+}: {
+  testID?: string
+  list: AppBskyGraphDefs.ListView
+  noBg?: boolean
+  noBorder?: boolean
+  renderButton?: () => JSX.Element
+}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  const rkey = React.useMemo(() => {
+    try {
+      const urip = new AtUri(list.uri)
+      return urip.rkey
+    } catch {
+      return ''
+    }
+  }, [list])
+
+  const descriptionRichText = React.useMemo(() => {
+    if (list.description) {
+      return new RichText({
+        text: list.description,
+        facets: list.descriptionFacets,
+      })
+    }
+    return undefined
+  }, [list])
+
+  return (
+    <Link
+      testID={testID}
+      style={[
+        styles.outer,
+        pal.border,
+        noBorder && styles.outerNoBorder,
+        !noBg && pal.view,
+      ]}
+      href={`/profile/${list.creator.did}/lists/${rkey}`}
+      title={list.name}
+      asAnchor
+      anchorNoUnderline>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <UserAvatar size={40} avatar={list.avatar} />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text
+            type="lg"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(list.name)}
+          </Text>
+          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+            {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
+            {list.creator.did === store.me.did
+              ? 'you'
+              : `@${list.creator.handle}`}
+          </Text>
+          {!!list.viewer?.muted && (
+            <View style={s.flexRow}>
+              <View style={[s.mt5, pal.btn, styles.pill]}>
+                <Text type="xs" style={pal.text}>
+                  Subscribed
+                </Text>
+              </View>
+            </View>
+          )}
+        </View>
+        {renderButton ? (
+          <View style={styles.layoutButton}>{renderButton()}</View>
+        ) : undefined}
+      </View>
+      {descriptionRichText ? (
+        <View style={styles.details}>
+          <RichTextCom
+            style={pal.text}
+            numberOfLines={20}
+            richText={descriptionRichText}
+          />
+        </View>
+      ) : undefined}
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    paddingHorizontal: 6,
+  },
+  outerNoBorder: {
+    borderTopWidth: 0,
+  },
+  layout: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  layoutAvi: {
+    width: 54,
+    paddingLeft: 4,
+    paddingTop: 8,
+    paddingBottom: 10,
+  },
+  avi: {
+    width: 40,
+    height: 40,
+    borderRadius: 20,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  layoutButton: {
+    paddingRight: 10,
+  },
+  details: {
+    paddingLeft: 54,
+    paddingRight: 10,
+    paddingBottom: 10,
+  },
+  pill: {
+    borderRadius: 4,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+  },
+  btn: {
+    paddingVertical: 7,
+    borderRadius: 50,
+    marginLeft: 6,
+    paddingHorizontal: 14,
+  },
+})
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx
new file mode 100644
index 000000000..32cafdb83
--- /dev/null
+++ b/src/view/com/lists/ListItems.tsx
@@ -0,0 +1,387 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+import {FlatList} from '../util/Views'
+import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {ProfileCard} from '../profile/ProfileCard'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import {RichText as RichTextCom} from '../util/text/RichText'
+import {UserAvatar} from '../util/UserAvatar'
+import {TextLink} from '../util/Link'
+import {ListModel} from 'state/models/content/list'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+
+const LOADING_ITEM = {_reactKey: '__loading__'}
+const HEADER_ITEM = {_reactKey: '__header__'}
+const EMPTY_ITEM = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export const ListItems = observer(
+  ({
+    list,
+    style,
+    scrollElRef,
+    onPressTryAgain,
+    onToggleSubscribed,
+    onPressEditList,
+    onPressDeleteList,
+    renderEmptyState,
+    testID,
+    headerOffset = 0,
+  }: {
+    list: ListModel
+    style?: StyleProp<ViewStyle>
+    scrollElRef?: MutableRefObject<FlatList<any> | null>
+    onPressTryAgain?: () => void
+    onToggleSubscribed?: () => void
+    onPressEditList?: () => void
+    onPressDeleteList?: () => void
+    renderEmptyState?: () => JSX.Element
+    testID?: string
+    headerOffset?: number
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {track} = useAnalytics()
+    const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+    const data = React.useMemo(() => {
+      let items: any[] = [HEADER_ITEM]
+      if (list.hasLoaded) {
+        if (list.hasError) {
+          items = items.concat([ERROR_ITEM])
+        }
+        if (list.isEmpty) {
+          items = items.concat([EMPTY_ITEM])
+        } else {
+          items = items.concat(list.items)
+        }
+        if (list.loadMoreError) {
+          items = items.concat([LOAD_MORE_ERROR_ITEM])
+        }
+      } else if (list.isLoading) {
+        items = items.concat([LOADING_ITEM])
+      }
+      return items
+    }, [
+      list.hasError,
+      list.hasLoaded,
+      list.isLoading,
+      list.isEmpty,
+      list.items,
+      list.loadMoreError,
+    ])
+
+    // events
+    // =
+
+    const onRefresh = React.useCallback(async () => {
+      track('Lists:onRefresh')
+      setIsRefreshing(true)
+      try {
+        await list.refresh()
+      } catch (err) {
+        list.rootStore.log.error('Failed to refresh lists', err)
+      }
+      setIsRefreshing(false)
+    }, [list, track, setIsRefreshing])
+
+    const onEndReached = React.useCallback(async () => {
+      track('Lists:onEndReached')
+      try {
+        await list.loadMore()
+      } catch (err) {
+        list.rootStore.log.error('Failed to load more lists', err)
+      }
+    }, [list, track])
+
+    const onPressRetryLoadMore = React.useCallback(() => {
+      list.retryLoadMore()
+    }, [list])
+
+    const onPressEditMembership = React.useCallback(
+      (profile: AppBskyActorDefs.ProfileViewBasic) => {
+        store.shell.openModal({
+          name: 'list-add-remove-user',
+          subject: profile.did,
+          displayName: profile.displayName || profile.handle,
+          onUpdate() {
+            list.refresh()
+          },
+        })
+      },
+      [store, list],
+    )
+
+    // rendering
+    // =
+
+    const renderMemberButton = React.useCallback(
+      (profile: AppBskyActorDefs.ProfileViewBasic) => {
+        if (!list.isOwner) {
+          return null
+        }
+        return (
+          <Button
+            type="default"
+            label="Edit"
+            onPress={() => onPressEditMembership(profile)}
+          />
+        )
+      },
+      [list, onPressEditMembership],
+    )
+
+    const renderItem = React.useCallback(
+      ({item}: {item: any}) => {
+        if (item === EMPTY_ITEM) {
+          if (renderEmptyState) {
+            return renderEmptyState()
+          }
+          return <View />
+        } else if (item === HEADER_ITEM) {
+          return list.list ? (
+            <ListHeader
+              list={list.list}
+              isOwner={list.isOwner}
+              onToggleSubscribed={onToggleSubscribed}
+              onPressEditList={onPressEditList}
+              onPressDeleteList={onPressDeleteList}
+            />
+          ) : null
+        } else if (item === ERROR_ITEM) {
+          return (
+            <ErrorMessage
+              message={list.error}
+              onPressTryAgain={onPressTryAgain}
+            />
+          )
+        } else if (item === LOAD_MORE_ERROR_ITEM) {
+          return (
+            <LoadMoreRetryBtn
+              label="There was an issue fetching the list. Tap here to try again."
+              onPress={onPressRetryLoadMore}
+            />
+          )
+        } else if (item === LOADING_ITEM) {
+          return <ProfileCardFeedLoadingPlaceholder />
+        }
+        return (
+          <ProfileCard
+            testID={`user-${
+              (item as AppBskyGraphDefs.ListItemView).subject.handle
+            }`}
+            profile={(item as AppBskyGraphDefs.ListItemView).subject}
+            renderButton={renderMemberButton}
+          />
+        )
+      },
+      [
+        list,
+        onPressTryAgain,
+        onPressRetryLoadMore,
+        renderMemberButton,
+        onPressEditList,
+        onPressDeleteList,
+        onToggleSubscribed,
+        renderEmptyState,
+      ],
+    )
+
+    const Footer = React.useCallback(
+      () =>
+        list.isLoading ? (
+          <View style={styles.feedFooter}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <View />
+        ),
+      [list],
+    )
+
+    return (
+      <View testID={testID} style={style}>
+        {data.length > 0 && (
+          <FlatList
+            testID={testID ? `${testID}-flatlist` : undefined}
+            ref={scrollElRef}
+            data={data}
+            keyExtractor={item => item._reactKey}
+            renderItem={renderItem}
+            ListFooterComponent={Footer}
+            refreshControl={
+              <RefreshControl
+                refreshing={isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+                progressViewOffset={headerOffset}
+              />
+            }
+            contentContainerStyle={s.contentContainer}
+            style={{paddingTop: headerOffset}}
+            onEndReached={onEndReached}
+            onEndReachedThreshold={0.6}
+            removeClippedSubviews={true}
+            contentOffset={{x: 0, y: headerOffset * -1}}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+const ListHeader = observer(
+  ({
+    list,
+    isOwner,
+    onToggleSubscribed,
+    onPressEditList,
+    onPressDeleteList,
+  }: {
+    list: AppBskyGraphDefs.ListView
+    isOwner: boolean
+    onToggleSubscribed?: () => void
+    onPressEditList?: () => void
+    onPressDeleteList?: () => void
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const descriptionRT = React.useMemo(
+      () =>
+        list?.description &&
+        new RichText({text: list.description, facets: list.descriptionFacets}),
+      [list],
+    )
+    return (
+      <>
+        <View style={[styles.header, pal.border]}>
+          <View style={s.flex1}>
+            <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
+              {list.name}
+            </Text>
+            {list && (
+              <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
+                by{' '}
+                {list.creator.did === store.me.did ? (
+                  'you'
+                ) : (
+                  <TextLink
+                    text={`@${list.creator.handle}`}
+                    href={`/profile/${list.creator.did}`}
+                  />
+                )}
+              </Text>
+            )}
+            {descriptionRT && (
+              <RichTextCom
+                testID="listDescription"
+                style={[pal.text, styles.headerDescription]}
+                richText={descriptionRT}
+              />
+            )}
+            {isDesktopWeb && (
+              <View style={styles.headerBtns}>
+                {list.viewer?.muted ? (
+                  <Button
+                    type="inverted"
+                    label="Unsubscribe"
+                    accessibilityLabel="Unsubscribe"
+                    accessibilityHint=""
+                    onPress={onToggleSubscribed}
+                  />
+                ) : (
+                  <Button
+                    type="primary"
+                    label="Subscribe & Mute"
+                    accessibilityLabel="Subscribe and mute"
+                    accessibilityHint=""
+                    onPress={onToggleSubscribed}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Edit List"
+                    accessibilityLabel="Edit list"
+                    accessibilityHint=""
+                    onPress={onPressEditList}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Delete List"
+                    accessibilityLabel="Delete list"
+                    accessibilityHint=""
+                    onPress={onPressDeleteList}
+                  />
+                )}
+              </View>
+            )}
+          </View>
+          <View>
+            <UserAvatar avatar={list.avatar} size={64} />
+          </View>
+        </View>
+        <View style={[styles.fakeSelector, pal.border]}>
+          <View
+            style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
+            <Text type="md-medium" style={[pal.text]}>
+              Muted users
+            </Text>
+          </View>
+        </View>
+      </>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    gap: 12,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    paddingBottom: 16,
+    borderTopWidth: 1,
+  },
+  headerDescription: {
+    marginTop: 8,
+  },
+  headerBtns: {
+    flexDirection: 'row',
+    gap: 8,
+    marginTop: 12,
+  },
+  fakeSelector: {
+    flexDirection: 'row',
+    paddingHorizontal: isDesktopWeb ? 16 : 6,
+  },
+  fakeSelectorItem: {
+    paddingHorizontal: 12,
+    paddingBottom: 8,
+    borderBottomWidth: 3,
+  },
+  feedFooter: {paddingTop: 20},
+})
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
new file mode 100644
index 000000000..88b71acc0
--- /dev/null
+++ b/src/view/com/lists/ListsList.tsx
@@ -0,0 +1,240 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
+import {FlatList} from '../util/Views'
+import {ListCard} from './ListCard'
+import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import {ListsListModel} from 'state/models/lists/lists-list'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+const LOADING_ITEM = {_reactKey: '__loading__'}
+const CREATENEW_ITEM = {_reactKey: '__loading__'}
+const EMPTY_ITEM = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export const ListsList = observer(
+  ({
+    listsList,
+    showAddBtns,
+    style,
+    scrollElRef,
+    onPressTryAgain,
+    onPressCreateNew,
+    renderItem,
+    renderEmptyState,
+    testID,
+    headerOffset = 0,
+  }: {
+    listsList: ListsListModel
+    showAddBtns?: boolean
+    style?: StyleProp<ViewStyle>
+    scrollElRef?: MutableRefObject<FlatList<any> | null>
+    onPressCreateNew: () => void
+    onPressTryAgain?: () => void
+    renderItem?: (list: GraphDefs.ListView) => JSX.Element
+    renderEmptyState?: () => JSX.Element
+    testID?: string
+    headerOffset?: number
+  }) => {
+    const pal = usePalette('default')
+    const {track} = useAnalytics()
+    const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+    const data = React.useMemo(() => {
+      let items: any[] = []
+      if (listsList.hasLoaded) {
+        if (listsList.hasError) {
+          items = items.concat([ERROR_ITEM])
+        }
+        if (listsList.isEmpty) {
+          items = items.concat([EMPTY_ITEM])
+        } else {
+          if (showAddBtns) {
+            items = items.concat([CREATENEW_ITEM])
+          }
+          items = items.concat(listsList.lists)
+        }
+        if (listsList.loadMoreError) {
+          items = items.concat([LOAD_MORE_ERROR_ITEM])
+        }
+      } else if (listsList.isLoading) {
+        items = items.concat([LOADING_ITEM])
+      }
+      return items
+    }, [
+      listsList.hasError,
+      listsList.hasLoaded,
+      listsList.isLoading,
+      listsList.isEmpty,
+      listsList.lists,
+      listsList.loadMoreError,
+      showAddBtns,
+    ])
+
+    // events
+    // =
+
+    const onRefresh = React.useCallback(async () => {
+      track('Lists:onRefresh')
+      setIsRefreshing(true)
+      try {
+        await listsList.refresh()
+      } catch (err) {
+        listsList.rootStore.log.error('Failed to refresh lists', err)
+      }
+      setIsRefreshing(false)
+    }, [listsList, track, setIsRefreshing])
+
+    const onEndReached = React.useCallback(async () => {
+      track('Lists:onEndReached')
+      try {
+        await listsList.loadMore()
+      } catch (err) {
+        listsList.rootStore.log.error('Failed to load more lists', err)
+      }
+    }, [listsList, track])
+
+    const onPressRetryLoadMore = React.useCallback(() => {
+      listsList.retryLoadMore()
+    }, [listsList])
+
+    // rendering
+    // =
+
+    const renderItemInner = React.useCallback(
+      ({item}: {item: any}) => {
+        if (item === EMPTY_ITEM) {
+          if (renderEmptyState) {
+            return renderEmptyState()
+          }
+          return <View />
+        } else if (item === CREATENEW_ITEM) {
+          return <CreateNewItem onPress={onPressCreateNew} />
+        } else if (item === ERROR_ITEM) {
+          return (
+            <ErrorMessage
+              message={listsList.error}
+              onPressTryAgain={onPressTryAgain}
+            />
+          )
+        } else if (item === LOAD_MORE_ERROR_ITEM) {
+          return (
+            <LoadMoreRetryBtn
+              label="There was an issue fetching your lists. Tap here to try again."
+              onPress={onPressRetryLoadMore}
+            />
+          )
+        } else if (item === LOADING_ITEM) {
+          return <ProfileCardFeedLoadingPlaceholder />
+        }
+        return renderItem ? (
+          renderItem(item)
+        ) : (
+          <ListCard list={item} testID={`list-${item.name}`} />
+        )
+      },
+      [
+        listsList,
+        onPressTryAgain,
+        onPressRetryLoadMore,
+        onPressCreateNew,
+        renderItem,
+        renderEmptyState,
+      ],
+    )
+
+    const Footer = React.useCallback(
+      () =>
+        listsList.isLoading ? (
+          <View style={styles.feedFooter}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <View />
+        ),
+      [listsList],
+    )
+
+    return (
+      <View testID={testID} style={style}>
+        {data.length > 0 && (
+          <FlatList
+            testID={testID ? `${testID}-flatlist` : undefined}
+            ref={scrollElRef}
+            data={data}
+            keyExtractor={item => item._reactKey}
+            renderItem={renderItemInner}
+            ListFooterComponent={Footer}
+            refreshControl={
+              <RefreshControl
+                refreshing={isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+                progressViewOffset={headerOffset}
+              />
+            }
+            contentContainerStyle={s.contentContainer}
+            style={{paddingTop: headerOffset}}
+            onEndReached={onEndReached}
+            onEndReachedThreshold={0.6}
+            removeClippedSubviews={true}
+            contentOffset={{x: 0, y: headerOffset * -1}}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+function CreateNewItem({onPress}: {onPress: () => void}) {
+  const pal = usePalette('default')
+
+  return (
+    <View style={[styles.createNewContainer]}>
+      <Button type="default" onPress={onPress} style={styles.createNewButton}>
+        <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
+        <Text type="button" style={pal.text}>
+          New Mute List
+        </Text>
+      </Button>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  createNewContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingTop: 18,
+    paddingBottom: 16,
+  },
+  createNewButton: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+  },
+  feedFooter: {paddingTop: 20},
+})
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index ad7ff5a6d..a5c74bc0b 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -144,8 +144,11 @@ export function Component({onChanged}: {onChanged: () => void}) {
             </Text>
           </TouchableOpacity>
         </View>
-        <Text type="2xl-bold" style={[styles.titleMiddle, pal.text]}>
-          Change my handle
+        <Text
+          type="2xl-bold"
+          style={[styles.titleMiddle, pal.text]}
+          numberOfLines={1}>
+          Change handle
         </Text>
         <View style={styles.titleRight}>
           {isProcessing ? (
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 30b465562..1256bd420 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -7,23 +7,66 @@ import {useStores} from 'state/index'
 import {LabelPreference} from 'state/models/ui/preferences'
 import {s, colors, gradients} from 'lib/styles'
 import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {ToggleButton} from '../util/forms/ToggleButton'
 import {usePalette} from 'lib/hooks/usePalette'
 import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
-import {isDesktopWeb} from 'platform/detection'
+import {isDesktopWeb, isIOS} from 'platform/detection'
+import * as Toast from '../util/Toast'
 
 export const snapPoints = ['90%']
 
-export function Component({}: {}) {
+export const Component = observer(({}: {}) => {
   const store = useStores()
   const pal = usePalette('default')
+
+  React.useEffect(() => {
+    store.preferences.sync()
+  }, [store])
+
+  const onToggleAdultContent = React.useCallback(async () => {
+    if (isIOS) {
+      return
+    }
+    try {
+      await store.preferences.setAdultContentEnabled(
+        !store.preferences.adultContentEnabled,
+      )
+    } catch (e) {
+      Toast.show('There was an issue syncing your preferences with the server')
+      store.log.error('Failed to update preferences with server', {e})
+    }
+  }, [store])
+
   const onPressDone = React.useCallback(() => {
     store.shell.closeModal()
   }, [store])
 
   return (
-    <View testID="contentModerationModal" style={[pal.view, styles.container]}>
-      <Text style={[pal.text, styles.title]}>Content Moderation</Text>
+    <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
+      <Text style={[pal.text, styles.title]}>Content Filtering</Text>
       <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://staging.bsky.app"
+                text="staging.bsky.app"
+              />
+              .
+            </Text>
+          ) : (
+            <ToggleButton
+              type="default-light"
+              label="Enable Adult Content"
+              isSelected={store.preferences.adultContentEnabled}
+              onPress={onToggleAdultContent}
+              style={styles.toggleBtn}
+            />
+          )}
+        </View>
         <ContentLabelPref
           group="nsfw"
           disabled={!store.preferences.adultContentEnabled}
@@ -50,7 +93,7 @@ export function Component({}: {}) {
           testID="sendReportBtn"
           onPress={onPressDone}
           accessibilityRole="button"
-          accessibilityLabel="Confirm content moderation settings"
+          accessibilityLabel="Done"
           accessibilityHint="">
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
@@ -63,7 +106,7 @@ export function Component({}: {}) {
       </View>
     </View>
   )
-}
+})
 
 // TODO: Refactor this component to pass labels down to each tab
 const ContentLabelPref = observer(
@@ -76,6 +119,21 @@ const ContentLabelPref = observer(
   }) => {
     const store = useStores()
     const pal = usePalette('default')
+
+    const onChange = React.useCallback(
+      async (v: LabelPreference) => {
+        try {
+          await store.preferences.setContentLabelPref(group, v)
+        } catch (e) {
+          Toast.show(
+            'There was an issue syncing your preferences with the server',
+          )
+          store.log.error('Failed to update preferences with server', {e})
+        }
+      },
+      [store, group],
+    )
+
     return (
       <View style={[styles.contentLabelPref, pal.border]}>
         <View style={s.flex1}>
@@ -95,7 +153,7 @@ const ContentLabelPref = observer(
         ) : (
           <SelectGroup
             current={store.preferences.contentLabels[group]}
-            onChange={v => store.preferences.setContentLabelPref(group, v)}
+            onChange={onChange}
             group={group}
           />
         )}
@@ -250,4 +308,7 @@ const styles = StyleSheet.create({
     padding: 14,
     backgroundColor: colors.gray1,
   },
+  toggleBtn: {
+    paddingHorizontal: 0,
+  },
 })
diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx
new file mode 100644
index 000000000..0c13f243a
--- /dev/null
+++ b/src/view/com/modals/CreateOrEditMuteList.tsx
@@ -0,0 +1,279 @@
+import React, {useState, useCallback} from 'react'
+import * as Toast from '../util/Toast'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  ScrollView,
+  StyleSheet,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {Text} from '../util/text/Text'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {useStores} from 'state/index'
+import {ListModel} from 'state/models/content/list'
+import {s, colors, gradients} from 'lib/styles'
+import {enforceLen} from 'lib/strings/helpers'
+import {compressIfNeeded} from 'lib/media/manip'
+import {UserAvatar} from '../util/UserAvatar'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {useAnalytics} from 'lib/analytics'
+import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {isDesktopWeb} from 'platform/detection'
+
+const MAX_NAME = 64 // todo
+const MAX_DESCRIPTION = 300 // todo
+
+export const snapPoints = ['fullscreen']
+
+export function Component({
+  onSave,
+  list,
+}: {
+  onSave?: (uri: string) => void
+  list?: ListModel
+}) {
+  const store = useStores()
+  const [error, setError] = useState<string>('')
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const {track} = useAnalytics()
+
+  const [isProcessing, setProcessing] = useState<boolean>(false)
+  const [name, setName] = useState<string>(list?.list.name || '')
+  const [description, setDescription] = useState<string>(
+    list?.list.description || '',
+  )
+  const [avatar, setAvatar] = useState<string | undefined>(list?.list.avatar)
+  const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
+
+  const onPressCancel = useCallback(() => {
+    store.shell.closeModal()
+  }, [store])
+
+  const onSelectNewAvatar = useCallback(
+    async (img: RNImage | null) => {
+      if (!img) {
+        setNewAvatar(null)
+        setAvatar(null)
+        return
+      }
+      track('CreateMuteList:AvatarSelected')
+      try {
+        const finalImg = await compressIfNeeded(img, 1000000)
+        setNewAvatar(finalImg)
+        setAvatar(finalImg.path)
+      } catch (e: any) {
+        setError(cleanError(e))
+      }
+    },
+    [track, setNewAvatar, setAvatar, setError],
+  )
+
+  const onPressSave = useCallback(async () => {
+    track('CreateMuteList:Save')
+    const nameTrimmed = name.trim()
+    if (!nameTrimmed) {
+      setError('Name is required')
+      return
+    }
+    setProcessing(true)
+    if (error) {
+      setError('')
+    }
+    try {
+      if (list) {
+        await list.updateMetadata({
+          name: nameTrimmed,
+          description: description.trim(),
+          avatar: newAvatar,
+        })
+        Toast.show('Mute list updated')
+        onSave?.(list.uri)
+      } else {
+        const res = await ListModel.createModList(store, {
+          name,
+          description,
+          avatar: newAvatar,
+        })
+        Toast.show('Mute list created')
+        onSave?.(res.uri)
+      }
+      store.shell.closeModal()
+    } catch (e: any) {
+      if (isNetworkError(e)) {
+        setError(
+          'Failed to create the mute list. Check your internet connection and try again.',
+        )
+      } else {
+        setError(cleanError(e))
+      }
+    }
+    setProcessing(false)
+  }, [
+    track,
+    setProcessing,
+    setError,
+    error,
+    onSave,
+    store,
+    name,
+    description,
+    newAvatar,
+    list,
+  ])
+
+  return (
+    <KeyboardAvoidingView behavior="height">
+      <ScrollView
+        style={[pal.view, styles.container]}
+        testID="createOrEditMuteListModal">
+        <Text style={[styles.title, pal.text]}>
+          {list ? 'Edit Mute List' : 'New Mute List'}
+        </Text>
+        {error !== '' && (
+          <View style={styles.errorContainer}>
+            <ErrorMessage message={error} />
+          </View>
+        )}
+        <Text style={[styles.label, pal.text]}>List Avatar</Text>
+        <View style={[styles.avi, {borderColor: pal.colors.background}]}>
+          <UserAvatar
+            size={80}
+            avatar={avatar}
+            onSelectNewAvatar={onSelectNewAvatar}
+          />
+        </View>
+        <View style={styles.form}>
+          <View>
+            <Text style={[styles.label, pal.text]} nativeID="list-name">
+              List Name
+            </Text>
+            <TextInput
+              testID="editNameInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="e.g. Spammers"
+              placeholderTextColor={colors.gray4}
+              value={name}
+              onChangeText={v => setName(enforceLen(v, MAX_NAME))}
+              accessible={true}
+              accessibilityLabel="Name"
+              accessibilityHint=""
+              accessibilityLabelledBy="list-name"
+            />
+          </View>
+          <View style={s.pb10}>
+            <Text style={[styles.label, pal.text]} nativeID="list-description">
+              Description
+            </Text>
+            <TextInput
+              testID="editDescriptionInput"
+              style={[styles.textArea, pal.border, pal.text]}
+              placeholder="e.g. Users that repeatedly reply with ads."
+              placeholderTextColor={colors.gray4}
+              keyboardAppearance={theme.colorScheme}
+              multiline
+              value={description}
+              onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
+              accessible={true}
+              accessibilityLabel="Description"
+              accessibilityHint=""
+              accessibilityLabelledBy="list-description"
+            />
+          </View>
+          {isProcessing ? (
+            <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
+              <ActivityIndicator />
+            </View>
+          ) : (
+            <TouchableOpacity
+              testID="saveBtn"
+              style={s.mt10}
+              onPress={onPressSave}
+              accessibilityRole="button"
+              accessibilityLabel="Save"
+              accessibilityHint="Creates the mute list">
+              <LinearGradient
+                colors={[gradients.blueLight.start, gradients.blueLight.end]}
+                start={{x: 0, y: 0}}
+                end={{x: 1, y: 1}}
+                style={[styles.btn]}>
+                <Text style={[s.white, s.bold]}>Save</Text>
+              </LinearGradient>
+            </TouchableOpacity>
+          )}
+          <TouchableOpacity
+            testID="cancelBtn"
+            style={s.mt5}
+            onPress={onPressCancel}
+            accessibilityRole="button"
+            accessibilityLabel="Cancel"
+            accessibilityHint=""
+            onAccessibilityEscape={onPressCancel}>
+            <View style={[styles.btn]}>
+              <Text style={[s.black, s.bold, pal.text]}>Cancel</Text>
+            </View>
+          </TouchableOpacity>
+        </View>
+      </ScrollView>
+    </KeyboardAvoidingView>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    paddingHorizontal: isDesktopWeb ? 0 : 16,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 18,
+  },
+  label: {
+    fontWeight: 'bold',
+    paddingHorizontal: 4,
+    paddingBottom: 4,
+    marginTop: 20,
+  },
+  form: {
+    paddingHorizontal: 6,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  textArea: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 12,
+    paddingTop: 10,
+    fontSize: 16,
+    height: 100,
+    textAlignVertical: 'top',
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 10,
+    marginBottom: 10,
+  },
+  avi: {
+    width: 84,
+    height: 84,
+    borderWidth: 2,
+    borderRadius: 42,
+    marginTop: 4,
+  },
+  errorContainer: {marginTop: 20},
+})
diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx
index 4a5d9bfde..eab472a78 100644
--- a/src/view/com/modals/EditImage.tsx
+++ b/src/view/com/modals/EditImage.tsx
@@ -18,148 +18,114 @@ import {Slider} from '@miblanchard/react-native-slider'
 import {MaterialIcons} from '@expo/vector-icons'
 import {observer} from 'mobx-react-lite'
 import {getKeys} from 'lib/type-assertions'
+import {isDesktopWeb} from 'platform/detection'
 
 export const snapPoints = ['80%']
 
+const RATIOS = {
+  '4:3': {
+    Icon: RectWideIcon,
+  },
+  '1:1': {
+    Icon: SquareIcon,
+  },
+  '3:4': {
+    Icon: RectTallIcon,
+  },
+  None: {
+    label: 'None',
+    Icon: MaterialIcons,
+    name: 'do-not-disturb-alt',
+  },
+} as const
+
+type AspectRatio = keyof typeof RATIOS
+
 interface Props {
   image: ImageModel
   gallery: GalleryModel
 }
 
-// This is only used for desktop web
 export const Component = observer(function ({image, gallery}: Props) {
   const pal = usePalette('default')
-  const store = useStores()
-  const {shell} = store
   const theme = useTheme()
-  const winDim = useWindowDimensions()
+  const store = useStores()
+  const windowDimensions = useWindowDimensions()
 
-  const [altText, setAltText] = useState(image.altText)
-  const [aspectRatio, setAspectRatio] = useState<AspectRatio>(
-    image.aspectRatio ?? 'None',
-  )
-  const [flipHorizontal, setFlipHorizontal] = useState<boolean>(
-    image.flipHorizontal ?? false,
-  )
-  const [flipVertical, setFlipVertical] = useState<boolean>(
-    image.flipVertical ?? false,
-  )
+  const {
+    aspectRatio,
+    // rotate = 0
+  } = image.attributes
 
-  // TODO: doesn't seem to be working correctly with crop
-  // const [rotation, setRotation] = useState(image.rotation ?? 0)
-  const [scale, setScale] = useState<number>(image.scale ?? 1)
-  const [position, setPosition] = useState<Position>()
-  const [isEditing, setIsEditing] = useState(false)
   const editorRef = useRef<ImageEditor>(null)
-
-  const imgEditorStyles = useMemo(() => {
-    const dim = Math.min(425, winDim.width - 24)
-    return {width: dim, height: dim}
-  }, [winDim.width])
-
-  const manipulationAttributes = useMemo(
-    () => ({
-      // TODO: doesn't seem to be working correctly with crop
-      // ...(rotation !== undefined ? {rotate: rotation} : {}),
-      ...(flipHorizontal !== undefined ? {flipHorizontal} : {}),
-      ...(flipVertical !== undefined ? {flipVertical} : {}),
-    }),
-    [flipHorizontal, flipVertical],
-  )
-
-  useEffect(() => {
-    const manipulateImage = async () => {
-      await image.manipulate(manipulationAttributes)
-    }
-
-    manipulateImage()
-  }, [image, manipulationAttributes])
-
-  const ratios = useMemo(
-    () =>
-      ({
-        '4:3': {
-          hint: 'Sets image aspect ratio to wide',
-          Icon: RectWideIcon,
-        },
-        '1:1': {
-          hint: 'Sets image aspect ratio to square',
-          Icon: SquareIcon,
-        },
-        '3:4': {
-          hint: 'Sets image aspect ratio to tall',
-          Icon: RectTallIcon,
-        },
-        None: {
-          label: 'None',
-          hint: 'Sets image aspect ratio to tall',
-          Icon: MaterialIcons,
-          name: 'do-not-disturb-alt',
-        },
-      } as const),
-    [],
+  const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
+  const [position, setPosition] = useState<Position | undefined>(
+    image.attributes.position,
   )
-
-  type AspectRatio = keyof typeof ratios
+  const [altText, setAltText] = useState('')
 
   const onFlipHorizontal = useCallback(() => {
-    setFlipHorizontal(!flipHorizontal)
-    image.manipulate({flipHorizontal})
-  }, [flipHorizontal, image])
+    image.flipHorizontal()
+  }, [image])
 
   const onFlipVertical = useCallback(() => {
-    setFlipVertical(!flipVertical)
-    image.manipulate({flipVertical})
-  }, [flipVertical, image])
+    image.flipVertical()
+  }, [image])
+
+  // const onSetRotate = useCallback(
+  //   (direction: 'left' | 'right') => {
+  //     const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
+  //     image.setRotate(rotation)
+  //   },
+  //   [rotate, image],
+  // )
+
+  const onSetRatio = useCallback(
+    (ratio: AspectRatio) => {
+      image.setRatio(ratio)
+    },
+    [image],
+  )
 
   const adjustments = useMemo(
-    () =>
-      [
-        // {
-        //   name: 'rotate-left',
-        //   label: 'Rotate left',
-        //   hint: 'Rotate image left',
-        //   onPress: () => {
-        //     const rotate = (rotation - 90) % 360
-        //     setRotation(rotate)
-        //     image.manipulate({rotate})
-        //   },
-        // },
-        // {
-        //   name: 'rotate-right',
-        //   label: 'Rotate right',
-        //   hint: 'Rotate image right',
-        //   onPress: () => {
-        //     const rotate = (rotation + 90) % 360
-        //     setRotation(rotate)
-        //     image.manipulate({rotate})
-        //   },
-        // },
-        {
-          name: 'flip',
-          label: 'Flip horizontal',
-          hint: 'Flip image horizontally',
-          onPress: onFlipHorizontal,
-        },
-        {
-          name: 'flip',
-          label: 'Flip vertically',
-          hint: 'Flip image vertically',
-          onPress: onFlipVertical,
-        },
-      ] as const,
+    () => [
+      // {
+      //   name: 'rotate-left' as const,
+      //   label: 'Rotate left',
+      //   onPress: () => {
+      //     onSetRotate('left')
+      //   },
+      // },
+      // {
+      //   name: 'rotate-right' as const,
+      //   label: 'Rotate right',
+      //   onPress: () => {
+      //     onSetRotate('right')
+      //   },
+      // },
+      {
+        name: 'flip' as const,
+        label: 'Flip horizontal',
+        onPress: onFlipHorizontal,
+      },
+      {
+        name: 'flip' as const,
+        label: 'Flip vertically',
+        onPress: onFlipVertical,
+      },
+    ],
     [onFlipHorizontal, onFlipVertical],
   )
 
   useEffect(() => {
     image.prev = image.compressed
-    setIsEditing(true)
+    image.prevAttributes = image.attributes
+    image.resetCompressed()
   }, [image])
 
   const onCloseModal = useCallback(() => {
-    shell.closeModal()
-    setIsEditing(false)
-  }, [shell])
+    store.shell.closeModal()
+  }, [store.shell])
 
   const onPressCancel = useCallback(async () => {
     await gallery.previous(image)
@@ -184,25 +150,12 @@ export const Component = observer(function ({image, gallery}: Props) {
             ...(position !== undefined ? {position} : {}),
           }
         : {}),
-      ...manipulationAttributes,
-      aspectRatio,
     })
 
-    image.prevAttributes = manipulationAttributes
+    image.prev = image.compressed
+    image.prevAttributes = image.attributes
     onCloseModal()
-  }, [
-    altText,
-    aspectRatio,
-    image,
-    manipulationAttributes,
-    position,
-    scale,
-    onCloseModal,
-  ])
-
-  const onPressRatio = useCallback((as: AspectRatio) => {
-    setAspectRatio(as)
-  }, [])
+  }, [altText, image, position, scale, onCloseModal])
 
   const getLabelIconSize = useCallback((as: AspectRatio) => {
     switch (as) {
@@ -220,40 +173,55 @@ export const Component = observer(function ({image, gallery}: Props) {
     return null
   }
 
-  const {width, height} = image.getDisplayDimensions(
-    aspectRatio,
-    imgEditorStyles.width,
-  )
+  const computedWidth =
+    windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
+  const sideLength = isDesktopWeb ? 300 : computedWidth
+
+  const dimensions = image.getDisplayDimensions(aspectRatio, sideLength)
+  const imgContainerStyles = {width: sideLength, height: sideLength}
+
+  const imgControlStyles = {
+    alignItems: 'center' as const,
+    flexDirection: isDesktopWeb ? ('row' as const) : ('column' as const),
+    gap: isDesktopWeb ? 5 : 0,
+  }
 
   return (
     <View testID="editImageModal" style={[pal.view, styles.container, s.flex1]}>
       <Text style={[styles.title, pal.text]}>Edit image</Text>
-      <View>
-        <View style={[styles.imgContainer, imgEditorStyles, pal.borderDark]}>
-          <ImageEditor
-            ref={editorRef}
-            style={styles.imgEditor}
-            image={isEditing ? image.compressed.path : image.path}
-            width={width}
-            height={height}
-            scale={scale}
-            border={0}
-            position={position}
-            onPositionChange={setPosition}
+      <View style={[styles.gap18, s.flexRow]}>
+        <View>
+          <View
+            style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
+            <ImageEditor
+              ref={editorRef}
+              style={styles.imgEditor}
+              image={image.compressed.path}
+              scale={scale}
+              border={0}
+              position={position}
+              onPositionChange={setPosition}
+              {...dimensions}
+            />
+          </View>
+          <Slider
+            value={scale}
+            onValueChange={(v: number | number[]) =>
+              setScale(Array.isArray(v) ? v[0] : v)
+            }
+            minimumValue={1}
+            maximumValue={3}
           />
         </View>
-        <Slider
-          value={scale}
-          onValueChange={(v: number | number[]) =>
-            setScale(Array.isArray(v) ? v[0] : v)
-          }
-          minimumValue={1}
-          maximumValue={3}
-        />
-        <View style={[s.flexRow, styles.gap18]}>
-          <View style={styles.imgControls}>
-            {getKeys(ratios).map(ratio => {
-              const {hint, Icon, ...props} = ratios[ratio]
+        <View>
+          {isDesktopWeb ? (
+            <Text type="sm-bold" style={pal.text}>
+              Ratios
+            </Text>
+          ) : null}
+          <View style={imgControlStyles}>
+            {getKeys(RATIOS).map(ratio => {
+              const {Icon, ...props} = RATIOS[ratio]
               const labelIconSize = getLabelIconSize(ratio)
               const isSelected = aspectRatio === ratio
 
@@ -261,10 +229,10 @@ export const Component = observer(function ({image, gallery}: Props) {
                 <Pressable
                   key={ratio}
                   onPress={() => {
-                    onPressRatio(ratio)
+                    onSetRatio(ratio)
                   }}
                   accessibilityLabel={ratio}
-                  accessibilityHint={hint}>
+                  accessibilityHint="">
                   <Icon
                     size={labelIconSize}
                     style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
@@ -281,18 +249,22 @@ export const Component = observer(function ({image, gallery}: Props) {
               )
             })}
           </View>
-          <View style={[styles.verticalSep, pal.border]} />
-          <View style={styles.imgControls}>
-            {adjustments.map(({label, hint, name, onPress}) => (
+          {isDesktopWeb ? (
+            <Text type="sm-bold" style={[pal.text, styles.subsection]}>
+              Transformations
+            </Text>
+          ) : null}
+          <View style={imgControlStyles}>
+            {adjustments.map(({label, name, onPress}) => (
               <Pressable
                 key={label}
                 onPress={onPress}
                 accessibilityLabel={label}
-                accessibilityHint={hint}
+                accessibilityHint=""
                 style={styles.flipBtn}>
                 <MaterialIcons
                   name={name}
-                  size={label.startsWith('Flip') ? 22 : 24}
+                  size={label?.startsWith('Flip') ? 22 : 24}
                   style={[
                     pal.text,
                     label === 'Flip vertically'
@@ -305,7 +277,10 @@ export const Component = observer(function ({image, gallery}: Props) {
           </View>
         </View>
       </View>
-      <View style={[styles.gap18]}>
+      <View style={[styles.gap18, styles.bottomSection, pal.border]}>
+        <Text type="sm-bold" style={pal.text} nativeID="alt-text">
+          Accessibility
+        </Text>
         <TextInput
           testID="altTextImageInput"
           style={[styles.textArea, pal.border, pal.text]}
@@ -313,11 +288,9 @@ export const Component = observer(function ({image, gallery}: Props) {
           multiline
           value={altText}
           onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
-          placeholder="Image description"
-          placeholderTextColor={pal.colors.textLight}
-          accessibilityLabel="Image alt text"
-          accessibilityHint="Sets image alt text for screenreaders"
-          accessibilityLabelledBy="imageAltText"
+          accessibilityLabel="Alt text"
+          accessibilityHint=""
+          accessibilityLabelledBy="alt-text"
         />
       </View>
       <View style={styles.btns}>
@@ -345,30 +318,16 @@ export const Component = observer(function ({image, gallery}: Props) {
 const styles = StyleSheet.create({
   container: {
     gap: 18,
-    paddingVertical: 18,
-    paddingHorizontal: 12,
+    paddingHorizontal: isDesktopWeb ? undefined : 16,
     height: '100%',
     width: '100%',
   },
-  gap18: {
-    gap: 18,
-  },
-
+  subsection: {marginTop: 12},
+  gap18: {gap: 18},
   title: {
     fontWeight: 'bold',
     fontSize: 24,
   },
-
-  textArea: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingTop: 10,
-    paddingHorizontal: 12,
-    fontSize: 16,
-    height: 100,
-    textAlignVertical: 'top',
-  },
-
   btns: {
     flexDirection: 'row',
     alignItems: 'center',
@@ -379,28 +338,12 @@ const styles = StyleSheet.create({
     paddingVertical: 8,
     paddingHorizontal: 24,
   },
-
-  verticalSep: {
-    borderLeftWidth: 1,
-  },
-
-  imgControls: {
-    flexDirection: 'row',
-    gap: 5,
-  },
   imgControl: {
     display: 'flex',
     alignItems: 'center',
     justifyContent: 'center',
     height: 40,
   },
-  flipVertical: {
-    transform: [{rotate: '90deg'}],
-  },
-  flipBtn: {
-    paddingHorizontal: 4,
-    paddingVertical: 8,
-  },
   imgEditor: {
     maxWidth: '100%',
   },
@@ -408,11 +351,29 @@ const styles = StyleSheet.create({
     display: 'flex',
     alignItems: 'center',
     justifyContent: 'center',
-    height: 425,
-    width: 425,
     borderWidth: 1,
-    borderRadius: 8,
     borderStyle: 'solid',
-    overflow: 'hidden',
+    marginBottom: 4,
+  },
+  flipVertical: {
+    transform: [{rotate: '90deg'}],
+  },
+  flipBtn: {
+    paddingHorizontal: 4,
+    paddingVertical: 8,
+  },
+  textArea: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingTop: 10,
+    paddingHorizontal: 12,
+    fontSize: 16,
+    height: 100,
+    textAlignVertical: 'top',
+    maxHeight: isDesktopWeb ? undefined : 50,
+  },
+  bottomSection: {
+    borderTopWidth: 1,
+    paddingTop: 18,
   },
 })
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index c26592fa9..f37a0f71a 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -65,7 +65,7 @@ export function Component({
   }
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
-      if (!img) {
+      if (img === null) {
         setNewUserAvatar(null)
         setUserAvatar(null)
         return
@@ -81,6 +81,7 @@ export function Component({
     },
     [track, setNewUserAvatar, setUserAvatar, setError],
   )
+
   const onSelectNewBanner = useCallback(
     async (img: RNImage | null) => {
       if (!img) {
@@ -99,6 +100,7 @@ export function Component({
     },
     [track, setNewUserBanner, setUserBanner, setError],
   )
+
   const onPressSave = useCallback(async () => {
     track('EditProfile:Save')
     setProcessing(true)
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index 52d6fa46a..b3fe9dd3f 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -57,7 +57,7 @@ export function Component({}: {}) {
         code works once!
       </Text>
       <Text type="sm" style={[styles.description, pal.textLight]}>
-        ( You'll receive one invite code every two weeks. )
+        (You'll receive one invite code every two weeks.)
       </Text>
       <ScrollView style={[styles.scrollContainer, pal.border]}>
         {store.me.invites.map((invite, i) => (
diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx
new file mode 100644
index 000000000..91fe67c17
--- /dev/null
+++ b/src/view/com/modals/ListAddRemoveUser.tsx
@@ -0,0 +1,253 @@
+import React, {useCallback} from 'react'
+import {observer} from 'mobx-react-lite'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {UserAvatar} from '../util/UserAvatar'
+import {ListsList} from '../lists/ListsList'
+import {ListsListModel} from 'state/models/lists/lists-list'
+import {ListMembershipModel} from 'state/models/content/list-membership'
+import {EmptyStateWithButton} from '../util/EmptyStateWithButton'
+import {Button} from '../util/forms/Button'
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb, isAndroid} from 'platform/detection'
+
+export const snapPoints = ['fullscreen']
+
+export const Component = observer(
+  ({
+    subject,
+    displayName,
+    onUpdate,
+  }: {
+    subject: string
+    displayName: string
+    onUpdate?: () => void
+  }) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const palPrimary = usePalette('primary')
+    const palInverted = usePalette('inverted')
+    const [selected, setSelected] = React.useState([])
+
+    const listsList: ListsListModel = React.useMemo(
+      () => new ListsListModel(store, store.me.did),
+      [store],
+    )
+    const memberships: ListMembershipModel = React.useMemo(
+      () => new ListMembershipModel(store, subject),
+      [store, subject],
+    )
+    React.useEffect(() => {
+      listsList.refresh()
+      memberships.fetch().then(
+        () => {
+          setSelected(memberships.memberships.map(m => m.value.list))
+        },
+        err => {
+          store.log.error('Failed to fetch memberships', {err})
+        },
+      )
+    }, [memberships, listsList, store, setSelected])
+
+    const onPressCancel = useCallback(() => {
+      store.shell.closeModal()
+    }, [store])
+
+    const onPressSave = useCallback(async () => {
+      try {
+        await memberships.updateTo(selected)
+      } catch (err) {
+        store.log.error('Failed to update memberships', {err})
+        return
+      }
+      Toast.show('Lists updated')
+      onUpdate?.()
+      store.shell.closeModal()
+    }, [store, selected, memberships, onUpdate])
+
+    const onPressNewMuteList = useCallback(() => {
+      store.shell.openModal({
+        name: 'create-or-edit-mute-list',
+        onSave: (_uri: string) => {
+          listsList.refresh()
+        },
+      })
+    }, [store, listsList])
+
+    const onToggleSelected = useCallback(
+      (uri: string) => {
+        if (selected.includes(uri)) {
+          setSelected(selected.filter(uri2 => uri2 !== uri))
+        } else {
+          setSelected([...selected, uri])
+        }
+      },
+      [selected, setSelected],
+    )
+
+    const renderItem = useCallback(
+      (list: GraphDefs.ListView) => {
+        const isSelected = selected.includes(list.uri)
+        return (
+          <Pressable
+            testID={`toggleBtn-${list.name}`}
+            style={[styles.listItem, pal.border]}
+            accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
+              list.name
+            }`}
+            accessibilityHint=""
+            onPress={() => onToggleSelected(list.uri)}>
+            <View style={styles.listItemAvi}>
+              <UserAvatar size={40} avatar={list.avatar} />
+            </View>
+            <View style={styles.listItemContent}>
+              <Text
+                type="lg"
+                style={[s.bold, pal.text]}
+                numberOfLines={1}
+                lineHeight={1.2}>
+                {sanitizeDisplayName(list.name)}
+              </Text>
+              <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'}{' '}
+                by{' '}
+                {list.creator.did === store.me.did
+                  ? 'you'
+                  : `@${list.creator.handle}`}
+              </Text>
+            </View>
+            <View
+              style={
+                isSelected
+                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
+                  : [styles.checkbox, pal.borderDark]
+              }>
+              {isSelected && (
+                <FontAwesomeIcon
+                  icon="check"
+                  style={palInverted.text as FontAwesomeIconStyle}
+                />
+              )}
+            </View>
+          </Pressable>
+        )
+      },
+      [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
+    )
+
+    const renderEmptyState = React.useCallback(() => {
+      return (
+        <EmptyStateWithButton
+          icon="users-slash"
+          message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
+          buttonLabel="New Mute List"
+          onPress={onPressNewMuteList}
+        />
+      )
+    }, [onPressNewMuteList])
+
+    return (
+      <View testID="listAddRemoveUserModal" style={s.hContentRegion}>
+        <Text style={[styles.title, pal.text]}>Add {displayName} to lists</Text>
+        <ListsList
+          listsList={listsList}
+          showAddBtns
+          onPressCreateNew={onPressNewMuteList}
+          renderItem={renderItem}
+          renderEmptyState={renderEmptyState}
+          style={[styles.list, pal.border]}
+        />
+        <View style={[styles.btns, pal.border]}>
+          <Button
+            testID="cancelBtn"
+            type="default"
+            onPress={onPressCancel}
+            style={styles.footerBtn}
+            accessibilityLabel="Cancel"
+            accessibilityHint=""
+            onAccessibilityEscape={onPressCancel}
+            label="Cancel"
+          />
+          <Button
+            testID="saveBtn"
+            type="primary"
+            onPress={onPressSave}
+            style={styles.footerBtn}
+            accessibilityLabel="Save changes"
+            accessibilityHint=""
+            onAccessibilityEscape={onPressSave}
+            label="Save Changes"
+          />
+        </View>
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  container: {
+    paddingHorizontal: isDesktopWeb ? 0 : 16,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 10,
+  },
+  list: {
+    flex: 1,
+    borderTopWidth: 1,
+  },
+  btns: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: 10,
+    paddingTop: 10,
+    paddingBottom: isAndroid ? 10 : 0,
+    borderTopWidth: 1,
+  },
+  footerBtn: {
+    paddingHorizontal: 24,
+    paddingVertical: 12,
+  },
+
+  listItem: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderTopWidth: 1,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+  },
+  listItemAvi: {
+    width: 54,
+    paddingLeft: 4,
+    paddingTop: 8,
+    paddingBottom: 10,
+  },
+  listItemContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  checkbox: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderWidth: 1,
+    width: 24,
+    height: 24,
+    borderRadius: 6,
+    marginRight: 8,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 18b7ae4c4..08ee74b02 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -12,6 +12,8 @@ import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as RepostModal from './Repost'
+import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
+import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as AltImageModal from './AltImage'
 import * as ReportAccountModal from './ReportAccount'
 import * as DeleteAccountModal from './DeleteAccount'
@@ -66,6 +68,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'report-account') {
     snapPoints = ReportAccountModal.snapPoints
     element = <ReportAccountModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'create-or-edit-mute-list') {
+    snapPoints = CreateOrEditMuteListModal.snapPoints
+    element = <CreateOrEditMuteListModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'list-add-remove-user') {
+    snapPoints = ListAddRemoveUserModal.snapPoints
+    element = <ListAddRemoveUserModal.Component {...activeModal} />
   } else if (activeModal?.name === 'delete-account') {
     snapPoints = DeleteAccountModal.snapPoints
     element = <DeleteAccountModal.Component />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index c9f2c4952..f2cf72a03 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -11,6 +11,8 @@ import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as ReportAccountModal from './ReportAccount'
+import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
+import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as CropImageModal from './crop-image/CropImage.web'
@@ -69,6 +71,10 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ReportPostModal.Component {...modal} />
   } else if (modal.name === 'report-account') {
     element = <ReportAccountModal.Component {...modal} />
+  } else if (modal.name === 'create-or-edit-mute-list') {
+    element = <CreateOrEditMuteListModal.Component {...modal} />
+  } else if (modal.name === 'list-add-remove-user') {
+    element = <ListAddRemoveUserModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
   } else if (modal.name === 'delete-account') {
diff --git a/src/view/com/modals/ReportPost.tsx b/src/view/com/modals/ReportPost.tsx
index 0695eed8e..ec75dc4c2 100644
--- a/src/view/com/modals/ReportPost.tsx
+++ b/src/view/com/modals/ReportPost.tsx
@@ -19,7 +19,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 
 const DMCA_LINK = 'https://bsky.app/support/copyright'
 
-export const snapPoints = [500]
+export const snapPoints = [550]
 
 export function Component({
   postUri,
@@ -73,6 +73,19 @@ export function Component({
         ),
       },
       {
+        key: ComAtprotoModerationDefs.REASONRUDE,
+        label: (
+          <View>
+            <Text style={pal.text} type="md-bold">
+              Anti-Social Behavior
+            </Text>
+            <Text style={pal.textLight}>
+              Harassment, trolling, or intolerance
+            </Text>
+          </View>
+        ),
+      },
+      {
         key: ComAtprotoModerationDefs.REASONVIOLATION,
         label: (
           <View>
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 6ef9d74e3..9294b6026 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -77,7 +77,7 @@ export function TabBar({
     ],
   )
 
-  const onLayout = () => {
+  const onLayout = React.useCallback(() => {
     const promises = []
     for (let i = 0; i < items.length; i++) {
       promises.push(
@@ -98,14 +98,17 @@ export function TabBar({
     Promise.all(promises).then((layouts: Layout[]) => {
       setItemLayouts(layouts)
     })
-  }
+  }, [containerRef, itemRefs, setItemLayouts, items.length])
 
-  const onPressItem = (index: number) => {
-    onSelect?.(index)
-    if (index === selectedPage) {
-      onPressSelected?.()
-    }
-  }
+  const onPressItem = React.useCallback(
+    (index: number) => {
+      onSelect?.(index)
+      if (index === selectedPage) {
+        onPressSelected?.()
+      }
+    },
+    [onSelect, onPressSelected, selectedPage],
+  )
 
   return (
     <View
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index b3da0b01b..610b96507 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -24,8 +24,10 @@ import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
 import {isDesktopWeb, isMobileWeb} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
 
 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
 const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
@@ -59,6 +61,13 @@ export const PostThread = observer(function PostThread({
     }
     return []
   }, [view.thread])
+  useSetTitle(
+    view.thread?.postRecord &&
+      `${sanitizeDisplayName(
+        view.thread.post.author.displayName ||
+          `@${view.thread.post.author.handle}`,
+      )}: "${view.thread?.postRecord?.text}"`,
+  )
 
   // events
   // =
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 563a3ead6..084e30a25 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -21,7 +21,7 @@ import {pluralize} from 'lib/strings/helpers'
 import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostCtrls} from '../util/PostCtrls'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {ImageHider} from '../util/moderation/ImageHider'
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 0b49995fe..614c5ea77 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -20,7 +20,7 @@ import {Link} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostCtrls} from '../util/PostCtrls'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
 import {ImageHider} from '../util/moderation/ImageHider'
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index b4708cf53..fa6131d61 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,11 +8,12 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {PostsFeedItemModel} from 'state/models/feeds/posts'
+import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
-import {PostCtrls} from '../util/PostCtrls'
+import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
@@ -31,13 +32,14 @@ export const FeedItem = observer(function ({
   isThreadChild,
   isThreadParent,
   showFollowBtn,
+  ignoreMuteFor,
 }: {
   item: PostsFeedItemModel
   isThreadChild?: boolean
   isThreadParent?: boolean
   showReplyLine?: boolean
   showFollowBtn?: boolean
-  ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
+  ignoreMuteFor?: string
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -142,12 +144,22 @@ export const FeedItem = observer(function ({
     isThreadParent ? styles.outerNoBottom : undefined,
   ]
 
+  // moderation override
+  let moderation = item.moderation.list
+  if (
+    ignoreMuteFor === item.post.author.did &&
+    moderation.isMute &&
+    !moderation.noOverride
+  ) {
+    moderation = {behavior: ModerationBehaviorCode.Show}
+  }
+
   return (
     <PostHider
       testID={`feedItem-by-${item.post.author.handle}`}
       style={outerStyles}
       href={itemHref}
-      moderation={item.moderation.list}>
+      moderation={moderation}>
       {isThreadChild && (
         <View
           style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@@ -237,7 +249,7 @@ export const FeedItem = observer(function ({
             </View>
           )}
           <ContentHider
-            moderation={item.moderation.list}
+            moderation={moderation}
             containerStyle={styles.contentHider}>
             {item.richText?.text ? (
               <View style={styles.postTextContainer}>
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 5a191ac10..824fd0c4b 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -19,7 +19,9 @@ export function FeedSlice({
   ignoreMuteFor?: string
 }) {
   if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
-    return null
+    if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
+      return null
+    }
   }
   if (slice.isThread && slice.items.length > 3) {
     const last = slice.items.length - 1
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 12d631833..42c4edef5 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -32,7 +32,7 @@ export const ProfileCard = observer(
     noBorder?: boolean
     followers?: AppBskyActorDefs.ProfileView[] | undefined
     overrideModeration?: boolean
-    renderButton?: () => JSX.Element
+    renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => JSX.Element
   }) => {
     const store = useStores()
     const pal = usePalette('default')
@@ -92,7 +92,7 @@ export const ProfileCard = observer(
             )}
           </View>
           {renderButton ? (
-            <View style={styles.layoutButton}>{renderButton()}</View>
+            <View style={styles.layoutButton}>{renderButton(profile)}</View>
           ) : undefined}
         </View>
         {profile.description ? (
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index dee788aff..3b9ca67c9 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -23,6 +23,7 @@ import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
@@ -30,6 +31,7 @@ import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics'
 import {NavigationProp} from 'lib/routes/types'
+import {listUriToHref} from 'lib/strings/url-helpers'
 import {isDesktopWeb, isNative} from 'platform/detection'
 import {FollowState} from 'state/models/cache/my-follows'
 import {shareUrl} from 'lib/sharing'
@@ -146,12 +148,21 @@ const ProfileHeaderLoaded = observer(
       navigation.push('ProfileFollows', {name: view.handle})
     }, [track, navigation, view])
 
-    const onPressShare = React.useCallback(async () => {
+    const onPressShare = React.useCallback(() => {
       track('ProfileHeader:ShareButtonClicked')
       const url = toShareUrl(`/profile/${view.handle}`)
       shareUrl(url)
     }, [track, view])
 
+    const onPressAddRemoveLists = React.useCallback(() => {
+      track('ProfileHeader:AddToListsButtonClicked')
+      store.shell.openModal({
+        name: 'list-add-remove-user',
+        subject: view.did,
+        displayName: view.displayName || view.handle,
+      })
+    }, [track, view, store])
+
     const onPressMuteAccount = React.useCallback(async () => {
       track('ProfileHeader:MuteAccountButtonClicked')
       try {
@@ -233,6 +244,11 @@ const ProfileHeaderLoaded = observer(
           label: 'Share',
           onPress: onPressShare,
         },
+        {
+          testID: 'profileHeaderDropdownListAddRemoveBtn',
+          label: 'Add to Lists',
+          onPress: onPressAddRemoveLists,
+        },
       ]
       if (!isMe) {
         items.push({sep: true})
@@ -269,6 +285,7 @@ const ProfileHeaderLoaded = observer(
       onPressUnblockAccount,
       onPressBlockAccount,
       onPressReportAccount,
+      onPressAddRemoveLists,
     ])
 
     const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
@@ -422,31 +439,42 @@ const ProfileHeaderLoaded = observer(
             {view.viewer.blocking ? (
               <View
                 testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.view, pal.border]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
-                <Text type="md" style={[s.mr2, pal.text]}>
+                style={[styles.moderationNotice, pal.viewLight]}>
+                <FontAwesomeIcon icon="ban" style={[pal.text]} />
+                <Text type="lg-medium" style={pal.text}>
                   Account blocked
                 </Text>
               </View>
             ) : view.viewer.muted ? (
               <View
                 testID="profileHeaderMutedNotice"
-                style={[styles.moderationNotice, pal.view, pal.border]}>
+                style={[styles.moderationNotice, pal.viewLight]}>
                 <FontAwesomeIcon
                   icon={['far', 'eye-slash']}
-                  style={[pal.text, s.mr5]}
+                  style={[pal.text]}
                 />
-                <Text type="md" style={[s.mr2, pal.text]}>
-                  Account muted
+                <Text type="lg-medium" style={pal.text}>
+                  Account muted{' '}
+                  {view.viewer.mutedByList && (
+                    <Text type="lg-medium" style={pal.text}>
+                      by{' '}
+                      <TextLink
+                        type="lg-medium"
+                        style={pal.link}
+                        href={listUriToHref(view.viewer.mutedByList.uri)}
+                        text={view.viewer.mutedByList.name}
+                      />
+                    </Text>
+                  )}
                 </Text>
               </View>
             ) : undefined}
             {view.viewer.blockedBy && (
               <View
                 testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.view, pal.border]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
-                <Text type="md" style={[s.mr2, pal.text]}>
+                style={[styles.moderationNotice, pal.viewLight]}>
+                <FontAwesomeIcon icon="ban" style={[pal.text]} />
+                <Text type="lg-medium" style={pal.text}>
                   This account has blocked you
                 </Text>
               </View>
@@ -595,10 +623,10 @@ const styles = StyleSheet.create({
   moderationNotice: {
     flexDirection: 'row',
     alignItems: 'center',
-    borderWidth: 1,
     borderRadius: 8,
-    paddingHorizontal: 12,
-    paddingVertical: 10,
+    paddingHorizontal: 16,
+    paddingVertical: 14,
+    gap: 8,
   },
 
   br40: {borderRadius: 40},
diff --git a/src/view/com/util/BlurView.web.tsx b/src/view/com/util/BlurView.web.tsx
index efcf40b9c..5267e6ade 100644
--- a/src/view/com/util/BlurView.web.tsx
+++ b/src/view/com/util/BlurView.web.tsx
@@ -14,7 +14,8 @@ export const BlurView = ({
   ...props
 }: React.PropsWithChildren<BlurViewProps>) => {
   // @ts-ignore using an RNW-specific attribute here -prf
-  style = addStyle(style, {backdropFilter: `blur(${blurAmount || 10}px`})
+  let blur = `blur(${blurAmount || 10}px`
+  style = addStyle(style, {backdropFilter: blur, WebkitBackdropFilter: blur})
   if (blurType === 'dark') {
     style = addStyle(style, styles.dark)
   } else {
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index 2b2c4e657..a495fcd3f 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -10,17 +10,19 @@ import {UserGroupIcon} from 'lib/icons'
 import {usePalette} from 'lib/hooks/usePalette'
 
 export function EmptyState({
+  testID,
   icon,
   message,
   style,
 }: {
+  testID?: string
   icon: IconProp | 'user-group'
   message: string
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
   return (
-    <View style={[styles.container, style]}>
+    <View testID={testID} style={[styles.container, style]}>
       <View style={styles.iconContainer}>
         {icon === 'user-group' ? (
           <UserGroupIcon size="64" style={styles.icon} />
diff --git a/src/view/com/util/EmptyStateWithButton.tsx b/src/view/com/util/EmptyStateWithButton.tsx
new file mode 100644
index 000000000..008ca2bdb
--- /dev/null
+++ b/src/view/com/util/EmptyStateWithButton.tsx
@@ -0,0 +1,88 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {Text} from './text/Text'
+import {Button} from './forms/Button'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+interface Props {
+  testID?: string
+  icon: IconProp
+  message: string
+  buttonLabel: string
+  onPress: () => void
+}
+
+export function EmptyStateWithButton(props: Props) {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+
+  return (
+    <View testID={props.testID} style={styles.container}>
+      <View style={styles.iconContainer}>
+        <FontAwesomeIcon
+          icon={props.icon}
+          style={[styles.icon, pal.text]}
+          size={62}
+        />
+      </View>
+      <Text type="xl-medium" style={[s.textCenter, pal.text]}>
+        {props.message}
+      </Text>
+      <View style={styles.btns}>
+        <Button
+          testID={props.testID ? `${props.testID}-button` : undefined}
+          type="inverted"
+          style={styles.btn}
+          onPress={props.onPress}>
+          <FontAwesomeIcon
+            icon="plus"
+            style={palInverted.text as FontAwesomeIconStyle}
+            size={14}
+          />
+          <Text type="lg-medium" style={palInverted.text}>
+            {props.buttonLabel}
+          </Text>
+        </Button>
+      </View>
+    </View>
+  )
+}
+const styles = StyleSheet.create({
+  container: {
+    height: '100%',
+    paddingVertical: 40,
+    paddingHorizontal: 30,
+  },
+  iconContainer: {
+    marginBottom: 16,
+  },
+  icon: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  btns: {
+    flexDirection: 'row',
+    justifyContent: 'center',
+  },
+  btn: {
+    gap: 10,
+    marginVertical: 20,
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 14,
+    paddingHorizontal: 24,
+    borderRadius: 30,
+  },
+  notice: {
+    borderRadius: 12,
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+    marginHorizontal: 30,
+  },
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index a2e607e47..f3679326f 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -66,6 +66,7 @@ export function UserAvatar({
           if (!(await requestCameraAccessIfNeeded())) {
             return
           }
+
           onSelectNewAvatar?.(
             await openCamera(store, {
               width: 1000,
@@ -83,20 +84,21 @@ export function UserAvatar({
           if (!(await requestPhotoAccessIfNeeded())) {
             return
           }
+
           const items = await openPicker(store, {
+            aspect: [1, 1],
+          })
+          const item = items[0]
+
+          const croppedImage = await openCropper(store, {
             mediaType: 'photo',
-            multiple: false,
+            cropperCircleOverlay: true,
+            height: item.height,
+            width: item.width,
+            path: item.path,
           })
 
-          onSelectNewAvatar?.(
-            await openCropper(store, {
-              mediaType: 'photo',
-              path: items[0].path,
-              width: 1000,
-              height: 1000,
-              cropperCircleOverlay: true,
-            }),
-          )
+          onSelectNewAvatar?.(croppedImage)
         },
       },
       {
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 51cfbccbb..6e08be505 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -55,10 +55,8 @@ export function UserBanner({
         if (!(await requestPhotoAccessIfNeeded())) {
           return
         }
-        const items = await openPicker(store, {
-          mediaType: 'photo',
-          multiple: false,
-        })
+        const items = await openPicker(store)
+
         onSelectNewBanner?.(
           await openCropper(store, {
             mediaType: 'photo',
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 7f5b5b7c2..97802394e 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -20,11 +20,13 @@ export const ViewHeader = observer(function ({
   canGoBack,
   hideOnScroll,
   showOnDesktop,
+  renderButton,
 }: {
   title: string
   canGoBack?: boolean
   hideOnScroll?: boolean
   showOnDesktop?: boolean
+  renderButton?: () => JSX.Element
 }) {
   const pal = usePalette('default')
   const store = useStores()
@@ -46,7 +48,7 @@ export const ViewHeader = observer(function ({
 
   if (isDesktopWeb) {
     if (showOnDesktop) {
-      return <DesktopWebHeader title={title} />
+      return <DesktopWebHeader title={title} renderButton={renderButton} />
     }
     return null
   } else {
@@ -79,13 +81,23 @@ export const ViewHeader = observer(function ({
             {title}
           </Text>
         </View>
-        <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+        {renderButton ? (
+          renderButton()
+        ) : (
+          <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+        )}
       </Container>
     )
   }
 })
 
-function DesktopWebHeader({title}: {title: string}) {
+function DesktopWebHeader({
+  title,
+  renderButton,
+}: {
+  title: string
+  renderButton?: () => JSX.Element
+}) {
   const pal = usePalette('default')
   return (
     <CenteredView style={[styles.header, styles.desktopHeader, pal.border]}>
@@ -94,6 +106,7 @@ function DesktopWebHeader({title}: {title: string}) {
           {title}
         </Text>
       </View>
+      {renderButton?.()}
     </CenteredView>
   )
 }
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 804192a37..9d6501d54 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -22,7 +22,7 @@ import {
   View,
   ViewProps,
 } from 'react-native'
-import {addStyle, colors} from 'lib/styles'
+import {addStyle} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 
 interface AddedProps {
@@ -124,12 +124,6 @@ const styles = StyleSheet.create({
     marginLeft: 'auto',
     marginRight: 'auto',
   },
-  containerLight: {
-    backgroundColor: colors.gray1,
-  },
-  containerDark: {
-    backgroundColor: colors.gray7,
-  },
   fixedHeight: {
     height: '100vh',
   },
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 1c9b1cf51..6a5f19f99 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -38,6 +38,7 @@ export function Button({
   accessibilityLabel,
   accessibilityHint,
   accessibilityLabelledBy,
+  onAccessibilityEscape,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
@@ -48,6 +49,7 @@ export function Button({
   accessibilityLabel?: string
   accessibilityHint?: string
   accessibilityLabelledBy?: string
+  onAccessibilityEscape?: () => void
 }>) {
   const theme = useTheme()
   const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(
@@ -126,6 +128,7 @@ export function Button({
       },
     },
   )
+
   const onPressWrapped = React.useCallback(
     (event: Event) => {
       event.stopPropagation()
@@ -134,15 +137,30 @@ export function Button({
     },
     [onPress],
   )
+
+  const getStyle = React.useCallback(
+    state => {
+      const arr = [typeOuterStyle, styles.outer, style]
+      if (state.pressed) {
+        arr.push({opacity: 0.6})
+      } else if (state.hovered) {
+        arr.push({opacity: 0.8})
+      }
+      return arr
+    },
+    [typeOuterStyle, style],
+  )
+
   return (
     <Pressable
-      style={[typeOuterStyle, styles.outer, style]}
+      style={getStyle}
       onPress={onPressWrapped}
       testID={testID}
       accessibilityRole="button"
       accessibilityLabel={accessibilityLabel}
       accessibilityHint={accessibilityHint}
-      accessibilityLabelledBy={accessibilityLabelledBy}>
+      accessibilityLabelledBy={accessibilityLabelledBy}
+      onAccessibilityEscape={onAccessibilityEscape}>
       {label ? (
         <Text type="button" style={[typeLabelStyle, labelStyle]}>
           {label}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index 04346d91f..36ef1f409 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -209,7 +209,7 @@ export function PostDropdownBtn({
       },
     },
     {sep: true},
-    {
+    !isAuthor && {
       testID: 'postDropdownReportBtn',
       icon: 'circle-exclamation',
       label: 'Report post',
@@ -339,7 +339,9 @@ const DropdownItems = ({
                     color={pal.text.color as string}
                   />
                 )}
-                <Text style={[styles.label, pal.text]}>{item.label}</Text>
+                <Text style={[styles.label, pal.text]} numberOfLines={1}>
+                  {item.label}
+                </Text>
               </TouchableOpacity>
             )
           } else if (isSep(item)) {
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 1a29b4530..723db289c 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -63,6 +63,5 @@ const styles = StyleSheet.create({
     position: 'absolute',
     left: 6,
     bottom: 6,
-    width: 46,
   },
 })
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 3be6b59f1..9980e9de0 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {
   StyleProp,
   StyleSheet,
@@ -18,18 +18,14 @@ import ReactNativeHapticFeedback, {
 //   TriggerableAnimated,
 //   TriggerableAnimatedRef,
 // } from './anim/TriggerableAnimated'
-import {Text} from './text/Text'
-import {PostDropdownBtn} from './forms/DropdownButton'
-import {
-  HeartIcon,
-  HeartIconSolid,
-  RepostIcon,
-  CommentBottomArrow,
-} from 'lib/icons'
+import {Text} from '../text/Text'
+import {PostDropdownBtn} from '../forms/DropdownButton'
+import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
 import {s, colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {useStores} from 'state/index'
-import {isIOS} from 'platform/detection'
+import {isIOS, isNative} from 'platform/detection'
+import {RepostButton} from './RepostButton'
 
 interface PostCtrlsOpts {
   itemUri: string
@@ -112,10 +108,12 @@ export function PostCtrls(opts: PostCtrlsOpts) {
   // DISABLED see #135
   // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
   // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
-  const onRepost = () => {
+  const onRepost = useCallback(() => {
     store.shell.closeModal()
     if (!opts.isReposted) {
-      ReactNativeHapticFeedback.trigger(hapticImpact)
+      if (isNative) {
+        ReactNativeHapticFeedback.trigger(hapticImpact)
+      }
       opts.onPressToggleRepost().catch(_e => undefined)
       // DISABLED see #135
       // repostRef.current?.trigger(
@@ -128,9 +126,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     } else {
       opts.onPressToggleRepost().catch(_e => undefined)
     }
-  }
+  }, [opts, store.shell])
 
-  const onQuote = () => {
+  const onQuote = useCallback(() => {
     store.shell.closeModal()
     store.shell.openComposer({
       quote: {
@@ -141,17 +139,18 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         indexedAt: opts.indexedAt,
       },
     })
-    ReactNativeHapticFeedback.trigger(hapticImpact)
-  }
 
-  const onPressToggleRepostWrapper = () => {
-    store.shell.openModal({
-      name: 'repost',
-      onRepost: onRepost,
-      onQuote: onQuote,
-      isReposted: opts.isReposted,
-    })
-  }
+    if (isNative) {
+      ReactNativeHapticFeedback.trigger(hapticImpact)
+    }
+  }, [
+    opts.author,
+    opts.indexedAt,
+    opts.itemCid,
+    opts.itemUri,
+    opts.text,
+    store.shell,
+  ])
 
   const onPressToggleLikeWrapper = async () => {
     if (!opts.isLiked) {
@@ -181,7 +180,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         onPress={opts.onPressReply}
         accessibilityRole="button"
         accessibilityLabel="Reply"
-        accessibilityHint="Opens reply composer">
+        accessibilityHint="reply composer">
         <CommentBottomArrow
           style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]}
           strokeWidth={3}
@@ -193,39 +192,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           </Text>
         ) : undefined}
       </TouchableOpacity>
-      <TouchableOpacity
-        testID="repostBtn"
-        hitSlop={HITSLOP}
-        onPress={onPressToggleRepostWrapper}
-        style={styles.ctrl}
-        accessibilityRole="button"
-        accessibilityLabel={opts.isReposted ? 'Undo repost' : 'Repost'}
-        accessibilityHint={
-          opts.isReposted
-            ? `Remove your repost of ${opts.author}'s post`
-            : `Repost or quote post ${opts.author}'s post`
-        }>
-        <RepostIcon
-          style={
-            opts.isReposted
-              ? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
-              : defaultCtrlColor
-          }
-          strokeWidth={2.4}
-          size={opts.big ? 24 : 20}
-        />
-        {typeof opts.repostCount !== 'undefined' ? (
-          <Text
-            testID="repostCount"
-            style={
-              opts.isReposted
-                ? [s.bold, s.green3, s.f15, s.ml5]
-                : [defaultCtrlColor, s.f15, s.ml5]
-            }>
-            {opts.repostCount}
-          </Text>
-        ) : undefined}
-      </TouchableOpacity>
+      <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
       <TouchableOpacity
         testID="likeBtn"
         style={styles.ctrl}
@@ -234,9 +201,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         accessibilityRole="button"
         accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'}
         accessibilityHint={
-          opts.isReposted
-            ? `Removes like from ${opts.author}'s post`
-            : `Like ${opts.author}'s post`
+          opts.isReposted ? `Removes like from the post` : `Like the post`
         }>
         {opts.isLiked ? (
           <HeartIconSolid
@@ -309,9 +274,6 @@ const styles = StyleSheet.create({
     padding: 5,
     margin: -5,
   },
-  ctrlIconReposted: {
-    color: colors.green3,
-  },
   ctrlIconLiked: {
     color: colors.red3,
   },
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
new file mode 100644
index 000000000..e6de4cb19
--- /dev/null
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -0,0 +1,95 @@
+import React, {useCallback} from 'react'
+import {StyleProp, StyleSheet, TouchableOpacity, ViewStyle} from 'react-native'
+import {RepostIcon} from 'lib/icons'
+import {s, colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {Text} from '../text/Text'
+import {useStores} from 'state/index'
+
+const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
+
+interface Props {
+  isReposted: boolean
+  repostCount?: number
+  big?: boolean
+  onRepost: () => void
+  onQuote: () => void
+}
+
+export const RepostButton = ({
+  isReposted,
+  repostCount,
+  big,
+  onRepost,
+  onQuote,
+}: Props) => {
+  const store = useStores()
+  const theme = useTheme()
+
+  const defaultControlColor = React.useMemo(
+    () => ({
+      color: theme.palette.default.postCtrl,
+    }),
+    [theme],
+  )
+
+  const onPressToggleRepostWrapper = useCallback(() => {
+    store.shell.openModal({
+      name: 'repost',
+      onRepost: onRepost,
+      onQuote: onQuote,
+      isReposted,
+    })
+  }, [onRepost, onQuote, isReposted, store.shell])
+
+  return (
+    <TouchableOpacity
+      testID="repostBtn"
+      hitSlop={HITSLOP}
+      onPress={onPressToggleRepostWrapper}
+      style={styles.control}
+      accessibilityRole="button"
+      accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'}
+      accessibilityHint={
+        isReposted
+          ? `Remove your repost of the post`
+          : `Repost or quote post the post`
+      }>
+      <RepostIcon
+        style={
+          isReposted
+            ? (styles.reposted as StyleProp<ViewStyle>)
+            : defaultControlColor
+        }
+        strokeWidth={2.4}
+        size={big ? 24 : 20}
+      />
+      {typeof repostCount !== 'undefined' ? (
+        <Text
+          testID="repostCount"
+          style={
+            isReposted
+              ? [s.bold, s.green3, s.f15, s.ml5]
+              : [defaultControlColor, s.f15, s.ml5]
+          }>
+          {repostCount}
+        </Text>
+      ) : undefined}
+    </TouchableOpacity>
+  )
+}
+
+const styles = StyleSheet.create({
+  control: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    padding: 5,
+    margin: -5,
+  },
+  reposted: {
+    color: colors.green3,
+  },
+  repostCount: {
+    color: 'currentColor',
+  },
+})
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
new file mode 100644
index 000000000..66cc0d123
--- /dev/null
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -0,0 +1,86 @@
+import React, {useMemo} from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {RepostIcon} from 'lib/icons'
+import {DropdownButton} from '../forms/DropdownButton'
+import {colors} from 'lib/styles'
+import {useTheme} from 'lib/ThemeContext'
+import {Text} from '../text/Text'
+
+interface Props {
+  isReposted: boolean
+  repostCount?: number
+  big?: boolean
+  onRepost: () => void
+  onQuote: () => void
+}
+
+export const RepostButton = ({
+  isReposted,
+  repostCount,
+  big,
+  onRepost,
+  onQuote,
+}: Props) => {
+  const theme = useTheme()
+
+  const defaultControlColor = React.useMemo(
+    () => ({
+      color: theme.palette.default.postCtrl,
+    }),
+    [theme],
+  )
+
+  const items = useMemo(
+    () => [
+      {
+        label: isReposted ? 'Undo repost' : 'Repost',
+        icon: 'retweet' as const,
+        onPress: onRepost,
+      },
+      {label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote},
+    ],
+    [isReposted, onRepost, onQuote],
+  )
+
+  return (
+    <DropdownButton
+      type="bare"
+      items={items}
+      bottomOffset={4}
+      openToRight
+      rightOffset={-40}>
+      <View
+        style={[
+          styles.control,
+          (isReposted
+            ? styles.reposted
+            : defaultControlColor) as StyleProp<ViewStyle>,
+        ]}>
+        <RepostIcon strokeWidth={2.4} size={big ? 24 : 20} />
+        {typeof repostCount !== 'undefined' ? (
+          <Text
+            testID="repostCount"
+            type={isReposted ? 'md-bold' : 'md-medium'}
+            style={styles.repostCount}>
+            {repostCount ?? 0}
+          </Text>
+        ) : undefined}
+      </View>
+    </DropdownButton>
+  )
+}
+
+const styles = StyleSheet.create({
+  control: {
+    display: 'flex',
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+  },
+  reposted: {
+    color: colors.green3,
+  },
+  repostCount: {
+    color: 'currentColor',
+  },
+})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 621bd7c0f..328b9305b 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -210,6 +210,5 @@ const styles = StyleSheet.create({
     position: 'absolute',
     left: 6,
     bottom: 6,
-    width: 46,
   },
 })
diff --git a/src/view/index.ts b/src/view/index.ts
index 9c8d023dd..84fc3f315 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -39,6 +39,8 @@ import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
+import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
+import {faHand as farHand} from '@fortawesome/free-regular-svg-icons/faHand'
 import {faHeart} from '@fortawesome/free-regular-svg-icons/faHeart'
 import {faHeart as fasHeart} from '@fortawesome/free-solid-svg-icons/faHeart'
 import {faHouse} from '@fortawesome/free-solid-svg-icons/faHouse'
@@ -47,6 +49,7 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage'
 import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo'
 import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage'
 import {faLink} from '@fortawesome/free-solid-svg-icons/faLink'
+import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
 import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
 import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
 import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage'
@@ -68,8 +71,10 @@ import {faRss} from '@fortawesome/free-solid-svg-icons/faRss'
 import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
 import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
 import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
+import {faUserSlash} from '@fortawesome/free-solid-svg-icons/faUserSlash'
 import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
 import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark'
+import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
 import {faX} from '@fortawesome/free-solid-svg-icons/faX'
@@ -119,6 +124,8 @@ export function setup() {
     farEyeSlash,
     faGear,
     faGlobe,
+    faHand,
+    farHand,
     faHeart,
     fasHeart,
     faHouse,
@@ -127,6 +134,7 @@ export function setup() {
     faInfo,
     faLanguage,
     faLink,
+    faListUl,
     faLock,
     faMagnifyingGlass,
     faMessage,
@@ -148,8 +156,10 @@ export function setup() {
     faUser,
     faUsers,
     faUserCheck,
+    faUserSlash,
     faUserPlus,
     faUserXmark,
+    faUsersSlash,
     faTicket,
     faTrashCan,
     faThumbtack,
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index cb1896b4a..3adbf09df 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -140,8 +140,8 @@ function AppPasswordsHeader() {
           pal.text,
           isDesktopWeb && styles.descriptionDesktop,
         ]}>
-        These passwords can be used to log onto Bluesky in other apps without
-        giving them full access to your account or your password.
+        Use app passwords to login to other Bluesky clients without giving full
+        access to your account or password.
       </Text>
     </>
   )
@@ -289,5 +289,6 @@ const styles = StyleSheet.create({
 
   trashIcon: {
     color: 'red',
+    minWidth: 16,
   },
 })
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 3db9a4378..1457478d5 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -62,7 +62,7 @@ export const HomeScreen = withAuthRequired(
         setSelectedPage(index)
         store.shell.setIsDrawerSwipeDisabled(index > 0)
       },
-      [store],
+      [store, setSelectedPage],
     )
 
     const onPressSelected = React.useCallback(() => {
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
new file mode 100644
index 000000000..4c52301cb
--- /dev/null
+++ b/src/view/screens/Moderation.tsx
@@ -0,0 +1,137 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {observer} from 'mobx-react-lite'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {CenteredView} from '../com/util/Views'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {Link} from '../com/util/Link'
+import {Text} from '../com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics'
+import {isDesktopWeb} from 'platform/detection'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
+export const ModerationScreen = withAuthRequired(
+  observer(function Moderation({}: Props) {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {screen, track} = useAnalytics()
+
+    useFocusEffect(
+      React.useCallback(() => {
+        screen('Moderation')
+        store.shell.setMinimalShellMode(false)
+      }, [screen, store]),
+    )
+
+    const onPressContentFiltering = React.useCallback(() => {
+      track('Moderation:ContentfilteringButtonClicked')
+      store.shell.openModal({name: 'content-filtering-settings'})
+    }, [track, store])
+
+    return (
+      <CenteredView
+        style={[
+          s.hContentRegion,
+          pal.border,
+          isDesktopWeb ? styles.desktopContainer : pal.viewLight,
+        ]}
+        testID="moderationScreen">
+        <ViewHeader title="Moderation" showOnDesktop />
+        <View style={styles.spacer} />
+        <TouchableOpacity
+          testID="contentFilteringBtn"
+          style={[styles.linkCard, pal.view]}
+          onPress={onPressContentFiltering}
+          accessibilityRole="tab"
+          accessibilityHint="Content filtering"
+          accessibilityLabel="">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="eye"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            Content filtering
+          </Text>
+        </TouchableOpacity>
+        <Link
+          testID="mutelistsBtn"
+          style={[styles.linkCard, pal.view]}
+          href="/moderation/mute-lists">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="users-slash"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            Mute lists
+          </Text>
+        </Link>
+        <Link
+          testID="mutedAccountsBtn"
+          style={[styles.linkCard, pal.view]}
+          href="/moderation/muted-accounts">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="user-slash"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            Muted accounts
+          </Text>
+        </Link>
+        <Link
+          testID="blockedAccountsBtn"
+          style={[styles.linkCard, pal.view]}
+          href="/moderation/blocked-accounts">
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="ban"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            Blocked accounts
+          </Text>
+        </Link>
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  spacer: {
+    height: 6,
+  },
+  linkCard: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 12,
+    paddingHorizontal: 18,
+    marginBottom: 1,
+  },
+  iconContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: 40,
+    height: 40,
+    borderRadius: 30,
+    marginRight: 12,
+  },
+})
diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index 195068510..cd506d630 100644
--- a/src/view/screens/BlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'>
-export const BlockedAccounts = withAuthRequired(
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'ModerationBlockedAccounts'
+>
+export const ModerationBlockedAccounts = withAuthRequired(
   observer(({}: Props) => {
     const pal = usePalette('default')
     const store = useStores()
diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx
new file mode 100644
index 000000000..0b81f432f
--- /dev/null
+++ b/src/view/screens/ModerationMuteLists.tsx
@@ -0,0 +1,122 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {AtUri} from '@atproto/api'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton'
+import {useStores} from 'state/index'
+import {ListsListModel} from 'state/models/lists/lists-list'
+import {ListsList} from 'view/com/lists/ListsList'
+import {Button} from 'view/com/util/forms/Button'
+import {NavigationProp} from 'lib/routes/types'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CenteredView} from 'view/com/util/Views'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {isDesktopWeb} from 'platform/detection'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'ModerationMuteLists'
+>
+export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => {
+  const pal = usePalette('default')
+  const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
+
+  const mutelists: ListsListModel = React.useMemo(
+    () => new ListsListModel(store, 'my-modlists'),
+    [store],
+  )
+
+  useFocusEffect(
+    React.useCallback(() => {
+      store.shell.setMinimalShellMode(false)
+      mutelists.refresh()
+    }, [store, mutelists]),
+  )
+
+  const onPressNewMuteList = React.useCallback(() => {
+    store.shell.openModal({
+      name: 'create-or-edit-mute-list',
+      onSave: (uri: string) => {
+        try {
+          const urip = new AtUri(uri)
+          navigation.navigate('ProfileList', {
+            name: urip.hostname,
+            rkey: urip.rkey,
+          })
+        } catch {}
+      },
+    })
+  }, [store, navigation])
+
+  const renderEmptyState = React.useCallback(() => {
+    return (
+      <EmptyStateWithButton
+        testID="emptyMuteLists"
+        icon="users-slash"
+        message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private."
+        buttonLabel="New Mute List"
+        onPress={onPressNewMuteList}
+      />
+    )
+  }, [onPressNewMuteList])
+
+  const renderHeaderButton = React.useCallback(
+    () => (
+      <Button
+        type="primary-light"
+        onPress={onPressNewMuteList}
+        style={styles.createBtn}>
+        <FontAwesomeIcon
+          icon="plus"
+          style={pal.link as FontAwesomeIconStyle}
+          size={18}
+        />
+      </Button>
+    ),
+    [onPressNewMuteList, pal],
+  )
+
+  return (
+    <CenteredView
+      style={[
+        styles.container,
+        isDesktopWeb && styles.containerDesktop,
+        pal.view,
+        pal.border,
+      ]}
+      testID="moderationMutelistsScreen">
+      <ViewHeader
+        title="Mute Lists"
+        showOnDesktop
+        renderButton={renderHeaderButton}
+      />
+      <ListsList
+        listsList={mutelists}
+        showAddBtns={isDesktopWeb}
+        renderEmptyState={renderEmptyState}
+        onPressCreateNew={onPressNewMuteList}
+      />
+    </CenteredView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  createBtn: {
+    width: 40,
+  },
+})
diff --git a/src/view/screens/MutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 8f3d1f8a5..22b8c0d33 100644
--- a/src/view/screens/MutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -22,8 +22,11 @@ import {ViewHeader} from '../com/util/ViewHeader'
 import {CenteredView} from 'view/com/util/Views'
 import {ProfileCard} from 'view/com/profile/ProfileCard'
 
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'MutedAccounts'>
-export const MutedAccounts = withAuthRequired(
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'ModerationMutedAccounts'
+>
+export const ModerationMutedAccounts = withAuthRequired(
   observer(({}: Props) => {
     const pal = usePalette('default')
     const store = useStores()
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 68aa97b66..9c8dd458c 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -7,12 +7,16 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewSelector} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
-import {ProfileUiModel} from 'state/models/ui/profile'
+import {ProfileUiModel, Sections} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
 import {PostsFeedSliceModel} from 'state/models/feeds/posts'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {FeedSlice} from '../com/posts/FeedSlice'
-import {PostFeedLoadingPlaceholder} from '../com/util/LoadingPlaceholder'
+import {ListCard} from 'view/com/lists/ListCard'
+import {
+  PostFeedLoadingPlaceholder,
+  ProfileCardFeedLoadingPlaceholder,
+} from '../com/util/LoadingPlaceholder'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {EmptyState} from '../com/util/EmptyState'
@@ -23,6 +27,8 @@ import {useAnalytics} from 'lib/analytics'
 import {ComposeIcon2} from 'lib/icons'
 import AlgoItem from 'view/com/algos/AlgoItem'
 import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {combinedDisplayName} from 'lib/strings/display-names'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
 export const ProfileScreen = withAuthRequired(
@@ -39,6 +45,7 @@ export const ProfileScreen = withAuthRequired(
       () => new ProfileUiModel(store, {user: route.params.name}),
       [route.params.name, store],
     )
+    useSetTitle(combinedDisplayName(uiState.profile))
 
     useFocusEffect(
       React.useCallback(() => {
@@ -113,53 +120,87 @@ export const ProfileScreen = withAuthRequired(
     }, [uiState.showLoadingMoreFooter])
     const renderItem = React.useCallback(
       (item: any) => {
-        if (item === ProfileUiModel.END_ITEM) {
-          return <Text style={styles.endItem}>- end of feed -</Text>
-        } else if (item === ProfileUiModel.LOADING_ITEM) {
-          return <PostFeedLoadingPlaceholder />
-        } else if (item._reactKey === '__error__') {
-          if (uiState.feed.isBlocking) {
+        if (uiState.selectedView === Sections.Lists) {
+          if (item === ProfileUiModel.LOADING_ITEM) {
+            return <ProfileCardFeedLoadingPlaceholder />
+          } else if (item._reactKey === '__error__') {
+            return (
+              <View style={s.p5}>
+                <ErrorMessage
+                  message={item.error}
+                  onPressTryAgain={onPressTryAgain}
+                />
+              </View>
+            )
+          } else if (item === ProfileUiModel.EMPTY_ITEM) {
             return (
               <EmptyState
-                icon="ban"
-                message="Posts hidden"
+                testID="listsEmpty"
+                icon="list-ul"
+                message="No lists yet!"
                 style={styles.emptyState}
               />
             )
+          } else {
+            return <ListCard testID={`list-${item.name}`} list={item} />
           }
-          if (uiState.feed.isBlockedBy) {
+        } else {
+          if (item === ProfileUiModel.END_ITEM) {
+            return <Text style={styles.endItem}>- end of feed -</Text>
+          } else if (item === ProfileUiModel.LOADING_ITEM) {
+            return <PostFeedLoadingPlaceholder />
+          } else if (item._reactKey === '__error__') {
+            if (uiState.feed.isBlocking) {
+              return (
+                <EmptyState
+                  icon="ban"
+                  message="Posts hidden"
+                  style={styles.emptyState}
+                />
+              )
+            }
+            if (uiState.feed.isBlockedBy) {
+              return (
+                <EmptyState
+                  icon="ban"
+                  message="Posts hidden"
+                  style={styles.emptyState}
+                />
+              )
+            }
+            return (
+              <View style={s.p5}>
+                <ErrorMessage
+                  message={item.error}
+                  onPressTryAgain={onPressTryAgain}
+                />
+              </View>
+            )
+          } else if (item === ProfileUiModel.EMPTY_ITEM) {
             return (
               <EmptyState
-                icon="ban"
-                message="Posts hidden"
+                icon={['far', 'message']}
+                message="No posts yet!"
                 style={styles.emptyState}
               />
             )
+          } else if (item instanceof PostsFeedSliceModel) {
+            return (
+              <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
+            )
+          } else if (item instanceof AlgoItemModel) {
+            return <AlgoItem item={item} />
           }
-          return (
-            <View style={s.p5}>
-              <ErrorMessage
-                message={item.error}
-                onPressTryAgain={onPressTryAgain}
-              />
-            </View>
-          )
-        } else if (item === ProfileUiModel.EMPTY_ITEM) {
-          return (
-            <EmptyState
-              icon={['far', 'message']}
-              message="No posts yet!"
-              style={styles.emptyState}
-            />
-          )
-        } else if (item instanceof PostsFeedSliceModel) {
-          return <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
-        } else if (item instanceof AlgoItemModel) {
-          return <AlgoItem item={item} />
         }
         return <View />
       },
-      [onPressTryAgain, uiState],
+      [
+        onPressTryAgain,
+        uiState.selectedView,
+        uiState.profile.did,
+        uiState.feed.isBlocking,
+        uiState.feed.isBlockedBy,
+      ],
     )
 
     return (
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
new file mode 100644
index 000000000..01f27bae1
--- /dev/null
+++ b/src/view/screens/ProfileList.tsx
@@ -0,0 +1,177 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {useNavigation} from '@react-navigation/native'
+import {observer} from 'mobx-react-lite'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {ListItems} from 'view/com/lists/ListItems'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {Button} from 'view/com/util/forms/Button'
+import * as Toast from 'view/com/util/Toast'
+import {ListModel} from 'state/models/content/list'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useSetTitle} from 'lib/hooks/useSetTitle'
+import {NavigationProp} from 'lib/routes/types'
+import {isDesktopWeb} from 'platform/detection'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
+export const ProfileListScreen = withAuthRequired(
+  observer(({route}: Props) => {
+    const store = useStores()
+    const navigation = useNavigation<NavigationProp>()
+    const pal = usePalette('default')
+    const {name, rkey} = route.params
+
+    const list: ListModel = React.useMemo(() => {
+      const model = new ListModel(
+        store,
+        `at://${name}/app.bsky.graph.list/${rkey}`,
+      )
+      return model
+    }, [store, name, rkey])
+    useSetTitle(list.list?.name)
+
+    useFocusEffect(
+      React.useCallback(() => {
+        store.shell.setMinimalShellMode(false)
+        list.loadMore(true)
+      }, [store, list]),
+    )
+
+    const onToggleSubscribed = React.useCallback(async () => {
+      try {
+        if (list.list?.viewer?.muted) {
+          await list.unsubscribe()
+        } else {
+          await list.subscribe()
+        }
+      } catch (err) {
+        Toast.show(
+          'There was an an issue updating your subscription, please check your internet connection and try again.',
+        )
+        store.log.error('Failed up update subscription', {err})
+      }
+    }, [store, list])
+
+    const onPressEditList = React.useCallback(() => {
+      store.shell.openModal({
+        name: 'create-or-edit-mute-list',
+        list,
+        onSave() {
+          list.refresh()
+        },
+      })
+    }, [store, list])
+
+    const onPressDeleteList = React.useCallback(() => {
+      store.shell.openModal({
+        name: 'confirm',
+        title: 'Delete List',
+        message: 'Are you sure?',
+        async onPressConfirm() {
+          await list.delete()
+          if (navigation.canGoBack()) {
+            navigation.goBack()
+          } else {
+            navigation.navigate('Home')
+          }
+        },
+      })
+    }, [store, list, navigation])
+
+    const renderEmptyState = React.useCallback(() => {
+      return <EmptyState icon="users-slash" message="This list is empty!" />
+    }, [])
+
+    const renderHeaderBtn = React.useCallback(() => {
+      return (
+        <View style={styles.headerBtns}>
+          {list?.isOwner && (
+            <Button
+              type="default"
+              label="Delete List"
+              testID="deleteListBtn"
+              accessibilityLabel="Delete list"
+              accessibilityHint=""
+              onPress={onPressDeleteList}
+            />
+          )}
+          {list?.isOwner && (
+            <Button
+              type="default"
+              label="Edit List"
+              testID="editListBtn"
+              accessibilityLabel="Edit list"
+              accessibilityHint=""
+              onPress={onPressEditList}
+            />
+          )}
+          {list.list?.viewer?.muted ? (
+            <Button
+              type="inverted"
+              label="Unsubscribe"
+              testID="unsubscribeListBtn"
+              accessibilityLabel="Unsubscribe from list"
+              accessibilityHint=""
+              onPress={onToggleSubscribed}
+            />
+          ) : (
+            <Button
+              type="primary"
+              label="Subscribe & Mute"
+              testID="subscribeListBtn"
+              accessibilityLabel="Subscribe to this list"
+              accessibilityHint="Mutes the users included in this list"
+              onPress={onToggleSubscribed}
+            />
+          )}
+        </View>
+      )
+    }, [
+      list?.isOwner,
+      list.list?.viewer?.muted,
+      onPressDeleteList,
+      onPressEditList,
+      onToggleSubscribed,
+    ])
+
+    return (
+      <CenteredView
+        style={[
+          styles.container,
+          isDesktopWeb && styles.containerDesktop,
+          pal.view,
+          pal.border,
+        ]}
+        testID="moderationMutelistsScreen">
+        <ViewHeader title="" renderButton={renderHeaderBtn} />
+        <ListItems
+          list={list}
+          renderEmptyState={renderEmptyState}
+          onToggleSubscribed={onToggleSubscribed}
+          onPressEditList={onPressEditList}
+          onPressDeleteList={onPressDeleteList}
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  headerBtns: {
+    flexDirection: 'row',
+    gap: 8,
+  },
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+})
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
index 6152038d3..f9b4864b2 100644
--- a/src/view/screens/SearchMobile.tsx
+++ b/src/view/screens/SearchMobile.tsx
@@ -121,7 +121,7 @@ export const SearchScreen = withAuthRequired(
       <TouchableWithoutFeedback onPress={onPress} accessible={false}>
         <View style={[pal.view, styles.container]}>
           <HeaderWithInput
-            isInputFocused={true}
+            isInputFocused={isInputFocused}
             query={query}
             setIsInputFocused={setIsInputFocused}
             onChangeQuery={onChangeQuery}
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 0c8ac3f1f..a919f11b0 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -127,11 +127,6 @@ export const SettingsScreen = withAuthRequired(
       store.shell.openModal({name: 'invite-codes'})
     }, [track, store])
 
-    const onPressContentFiltering = React.useCallback(() => {
-      track('Settings:ContentfilteringButtonClicked')
-      store.shell.openModal({name: 'content-filtering-settings'})
-    }, [track, store])
-
     const onPressContentLanguages = React.useCallback(() => {
       track('Settings:ContentlanguagesButtonClicked')
       store.shell.openModal({name: 'content-languages-settings'})
@@ -252,7 +247,9 @@ export const SettingsScreen = withAuthRequired(
               Add account
             </Text>
           </TouchableOpacity>
+
           <View style={styles.spacer20} />
+
           <Text type="xl-bold" style={[pal.text, styles.heading]}>
             Invite a friend
           </Text>
@@ -287,9 +284,6 @@ export const SettingsScreen = withAuthRequired(
 
           <View style={styles.spacer20} />
 
-          <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Moderation
-          </Text>
           <Link
             testID="bookmarkedAlgosBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
@@ -306,51 +300,7 @@ export const SettingsScreen = withAuthRequired(
               Custom Algorithms
             </Text>
           </Link>
-          <TouchableOpacity
-            testID="contentFilteringBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            onPress={isSwitching ? undefined : onPressContentFiltering}
-            accessibilityHint="Content moderation"
-            accessibilityLabel="Opens configurable content moderation settings">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="eye"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Content moderation
-            </Text>
-          </TouchableOpacity>
-          <Link
-            testID="mutedAccountsBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            href="/settings/muted-accounts">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon={['far', 'eye-slash']}
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Muted accounts
-            </Text>
-          </Link>
-          <Link
-            testID="blockedAccountsBtn"
-            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
-            href="/settings/blocked-accounts">
-            <View style={[styles.iconContainer, pal.btn]}>
-              <FontAwesomeIcon
-                icon="ban"
-                style={pal.text as FontAwesomeIconStyle}
-              />
-            </View>
-            <Text type="lg" style={pal.text}>
-              Blocked accounts
-            </Text>
-          </Link>
-          <View style={styles.spacer20} />
+
           <Text type="xl-bold" style={[pal.text, styles.heading]}>
             Advanced
           </Text>
@@ -398,8 +348,8 @@ export const SettingsScreen = withAuthRequired(
                 style={pal.text as FontAwesomeIconStyle}
               />
             </View>
-            <Text type="lg" style={pal.text}>
-              Change my handle
+            <Text type="lg" style={pal.text} numberOfLines={1}>
+              Change handle
             </Text>
           </TouchableOpacity>
           <View style={styles.spacer20} />
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index bdd64807e..2ecdbaab2 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -28,6 +28,7 @@ import {
   MagnifyingGlassIcon2Solid,
   MoonIcon,
   UserIconSolid,
+  HandIcon,
 } from 'lib/icons'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Text} from 'view/com/util/text/Text'
@@ -94,6 +95,12 @@ export const DrawerContent = observer(() => {
     onPressTab('MyProfile')
   }, [onPressTab])
 
+  const onPressModeration = React.useCallback(() => {
+    track('Menu:ItemClicked', {url: 'Moderation'})
+    navigation.navigate('Moderation')
+    store.shell.closeDrawer()
+  }, [navigation, track, store.shell])
+
   const onPressSettings = React.useCallback(() => {
     track('Menu:ItemClicked', {url: 'Settings'})
     navigation.navigate('Settings')
@@ -215,13 +222,30 @@ export const DrawerContent = observer(() => {
             }
             label="Notifications"
             accessibilityLabel="Notifications"
-            accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
+            accessibilityHint={
+              notifications.unreadCountLabel === ''
+                ? ''
+                : `${notifications.unreadCountLabel} unread`
+            }
             count={notifications.unreadCountLabel}
             bold={isAtNotifications}
             onPress={onPressNotifications}
           />
           <MenuItem
             icon={
+              <HandIcon
+                strokeWidth={5}
+                style={pal.text as FontAwesomeIconStyle}
+                size={24}
+              />
+            }
+            label="Moderation"
+            accessibilityLabel="Moderation"
+            accessibilityHint=""
+            onPress={onPressModeration}
+          />
+          <MenuItem
+            icon={
               isAtMyProfile ? (
                 <UserIconSolid
                   style={pal.text as StyleProp<ViewStyle>}
@@ -404,6 +428,7 @@ const styles = StyleSheet.create({
     flex: 1,
     paddingTop: 20,
     paddingBottom: 50,
+    maxWidth: 300,
   },
   viewDarkMode: {
     backgroundColor: '#1B1919',
diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx
index c11a0128c..ef9499f9f 100644
--- a/src/view/shell/bottom-bar/BottomBar.tsx
+++ b/src/view/shell/bottom-bar/BottomBar.tsx
@@ -38,6 +38,7 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
     useNavigationTabState()
 
   const {footerMinimalShellTransform} = useMinimalShellMode()
+  const {notifications} = store.me
 
   const onPressTab = React.useCallback(
     (tab: string) => {
@@ -138,11 +139,15 @@ export const BottomBar = observer(({navigation}: BottomTabBarProps) => {
           )
         }
         onPress={onPressNotifications}
-        notificationCount={store.me.notifications.unreadCountLabel}
+        notificationCount={notifications.unreadCountLabel}
         accessible={true}
         accessibilityRole="tab"
         accessibilityLabel="Notifications"
-        accessibilityHint={`${store.me.notifications.unreadCountLabel} unread`}
+        accessibilityHint={
+          notifications.unreadCountLabel === ''
+            ? ''
+            : `${notifications.unreadCountLabel} unread`
+        }
       />
       <Btn
         testID="bottomBarProfileBtn"
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index ca6330304..ba48dd2ae 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -29,6 +29,7 @@ import {
   CogIcon,
   CogIconSolid,
   ComposeIcon2,
+  HandIcon,
 } from 'lib/icons'
 import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers'
 import {NavigationProp} from 'lib/routes/types'
@@ -203,6 +204,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         }
         label="Notifications"
       />
+      <NavItem
+        href="/moderation"
+        icon={
+          <HandIcon
+            strokeWidth={5.5}
+            style={pal.text as FontAwesomeIconStyle}
+            size={24}
+          />
+        }
+        iconFilled={
+          <FontAwesomeIcon
+            icon="hand"
+            style={pal.text as FontAwesomeIconStyle}
+            size={20}
+          />
+        }
+        label="Moderation"
+      />
       {store.session.hasSession && (
         <NavItem
           href={`/profile/${store.me.handle}`}
@@ -279,7 +298,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'center',
-    width: 138,
+    width: 140,
     borderRadius: 24,
     paddingVertical: 10,
     paddingHorizontal: 16,
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index d6663ce3d..4fae64fef 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -11,13 +11,14 @@ import {s} from 'lib/styles'
 import {useStores} from 'state/index'
 import {pluralize} from 'lib/strings/helpers'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
-import {MoonIcon} from 'lib/icons'
+import {MoonIcon, SunIcon} from 'lib/icons'
 import {formatCount} from 'view/com/util/numeric/format'
 
 export const DesktopRightNav = observer(function DesktopRightNav() {
   const store = useStores()
   const pal = usePalette('default')
   const mode = useColorSchemeStyle('Light', 'Dark')
+  const otherMode = mode === 'Dark' ? 'Light' : 'Dark'
 
   const onDarkmodePress = React.useCallback(() => {
     store.shell.setDarkMode(!store.shell.darkMode)
@@ -71,10 +72,14 @@ export const DesktopRightNav = observer(function DesktopRightNav() {
               : 'Sets display to dark mode'
           }>
           <View style={[pal.viewLight, styles.darkModeToggleIcon]}>
-            <MoonIcon size={18} style={pal.textLight} />
+            {mode === 'Dark' ? (
+              <SunIcon size={18} style={pal.textLight} />
+            ) : (
+              <MoonIcon size={18} style={pal.textLight} />
+            )}
           </View>
           <Text type="sm" style={pal.textLight}>
-            {mode} mode
+            {otherMode} mode
           </Text>
         </TouchableOpacity>
       </View>
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 349376436..68ce370ed 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useEffect} from 'react'
 import {observer} from 'mobx-react-lite'
 import {View, StyleSheet, TouchableOpacity} from 'react-native'
 import {useStores} from 'state/index'
@@ -14,11 +14,21 @@ import {RoutesContainer, FlatNavigator} from '../../Navigation'
 import {DrawerContent} from './Drawer'
 import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
 import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
 
 const ShellInner = observer(() => {
   const store = useStores()
   const {isDesktop} = useWebMediaQueries()
 
+  const navigator = useNavigation<NavigationProp>()
+
+  useEffect(() => {
+    navigator.addListener('state', () => {
+      store.shell.closeAnyActiveElement()
+    })
+  }, [navigator, store.shell])
+
   return (
     <>
       <View style={s.hContentRegion}>
diff --git a/yarn.lock b/yarn.lock
index a523c103c..caedf53b4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -29,7 +29,7 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@*", "@atproto/api@0.2.11":
+"@atproto/api@*":
   version "0.2.11"
   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.11.tgz#53b70b0f4942b2e2dd5cb46433f133cde83917bf"
   integrity sha512-5JY1Ii/81Bcy1ZTGRqALsaOdc8fIJTSlMNoSptpGH73uAPQE93weDrb8sc3KoxWi1G2ss3IIBSLPJWxALocJSQ==
@@ -40,6 +40,17 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
+"@atproto/api@0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.3.3.tgz#8c8d41567beb7b37217f76d2aacf2c280e9fd07e"
+  integrity sha512-BlgpYbdPO0KSBypg2KgqHM0kS2Pk82P3X0w2rJs/vrdcMl72d2WeI9kQ5PPFiz80p6C6XcLcpnzzKKtQeFvh4A==
+  dependencies:
+    "@atproto/common-web" "*"
+    "@atproto/uri" "*"
+    "@atproto/xrpc" "*"
+    tlds "^1.234.0"
+    typed-emitter "^2.1.0"
+
 "@atproto/common-web@*":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.1.0.tgz#5529fa66f9533aa00cfd13f0a25757df7b26bd3d"
@@ -137,10 +148,10 @@
   resolved "https://registry.yarnpkg.com/@atproto/nsid/-/nsid-0.0.1.tgz#0cdc00cefe8f0b1385f352b9f57b3ad37fff09a4"
   integrity sha512-t5M6/CzWBVYoBbIvfKDpqPj/+ZmyoK9ydZSStcTXosJ27XXwOPhz0VDUGKK2SM9G5Y7TPes8S5KTAU0UdVYFCw==
 
-"@atproto/pds@^0.1.5":
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.5.tgz#59411497f2d85b6706ab793e8f7f618bdb8c51a3"
-  integrity sha512-QtTf2mbqO5MEsrXPTFU43dSb0WT3TzaLw5mL++9w18CZDMvdmv2uJXKeaSiU+u3WJEtRpRs5hoLSdfrJ2i3PuA==
+"@atproto/pds@^0.1.8":
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.1.8.tgz#cf1a9bab2301c3fe1120c63576153ac5a20bf70d"
+  integrity sha512-I493U+/NNU9D8L8tVbM/OpD6gQ6/Mv7uE+/i4a1vfBGO6NqYJ6jKw3qeCy4jq3NVbTxcs+lSSpK27hgApx4PtA==
   dependencies:
     "@atproto/api" "*"
     "@atproto/common" "*"
@@ -8720,17 +8731,17 @@ expo-image-manipulator@^11.1.1:
   dependencies:
     expo-image-loader "~4.1.0"
 
-expo-image-picker@~14.1.1:
+expo-image-picker@^14.1.1:
   version "14.1.1"
   resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-14.1.1.tgz#181f1348ba6a43df7b87cee4a601d45c79b7c2d7"
   integrity sha512-SvWtnkLW7jp5Ntvk3lVcRQmhFYja8psmiR7O6P/+7S6f4llt3vaFwb4I3+pUXqJxxpi7BHc2+95qOLf0SFOIag==
   dependencies:
     expo-image-loader "~4.1.0"
 
-expo-image@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.1.tgz#3f377cb3142de2107903f4e4f88a7f44785dee18"
-  integrity sha512-pYZFN0ctuIBA+sqUiw70rHQQ04WDyEcF549ObArdj0MNgSUCBJMFmu/jrWDmxOpEMF40lfLVIZKigJT7Bw+GYA==
+expo-image@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.3.tgz#f3582d725ffb7437f8ce946ad44fe33f0aa0603d"
+  integrity sha512-+Mnx6rcneWSUGfHkUDV3cQ3R4lVwoIDFs/tcXVqnlxyNJdNxpW2cge9pS2Hpj3UDoZVhZPLR8LHS8E9wEaC0NA==
 
 expo-json-utils@~0.5.0:
   version "0.5.1"