about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-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/ContentFilteringSettings.tsx6
-rw-r--r--src/view/com/modals/CreateOrEditMuteList.tsx273
-rw-r--r--src/view/com/modals/ListAddRemoveUser.tsx255
-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/pager/TabBar.tsx19
-rw-r--r--src/view/com/posts/FeedItem.tsx18
-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/EmptyState.tsx4
-rw-r--r--src/view/com/util/EmptyStateWithButton.tsx88
-rw-r--r--src/view/com/util/ViewHeader.tsx19
-rw-r--r--src/view/index.ts10
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Moderation.tsx136
-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.tsx95
-rw-r--r--src/view/screens/ProfileList.tsx175
-rw-r--r--src/view/screens/Settings.tsx55
-rw-r--r--src/view/shell/Drawer.tsx19
-rw-r--r--src/view/shell/desktop/LeftNav.tsx18
27 files changed, 2064 insertions, 124 deletions
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..52b728cb9
--- /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 from this list"
+                    accessibilityHint="Stops muting the users included in this list"
+                    onPress={onToggleSubscribed}
+                  />
+                ) : (
+                  <Button
+                    type="primary"
+                    label="Subscribe & Mute"
+                    accessibilityLabel="Subscribe to this list"
+                    accessibilityHint="Mutes the users included in this list"
+                    onPress={onToggleSubscribed}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Edit List"
+                    accessibilityLabel="Edit list"
+                    accessibilityHint="Opens a modal to edit the mutelist"
+                    onPress={onPressEditList}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Delete List"
+                    accessibilityLabel="Delete list"
+                    accessibilityHint="Deletes the mutelist"
+                    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/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index 30b465562..5db0ef5a5 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -21,8 +21,8 @@ export function Component({}: {}) {
   }, [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}>
         <ContentLabelPref
           group="nsfw"
@@ -50,7 +50,7 @@ export function Component({}: {}) {
           testID="sendReportBtn"
           onPress={onPressDone}
           accessibilityRole="button"
-          accessibilityLabel="Confirm content moderation settings"
+          accessibilityLabel="Confirm content filtering settings"
           accessibilityHint="">
           <LinearGradient
             colors={[gradients.blueLight.start, gradients.blueLight.end]}
diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx
new file mode 100644
index 000000000..0970770e2
--- /dev/null
+++ b/src/view/com/modals/CreateOrEditMuteList.tsx
@@ -0,0 +1,273 @@
+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]}>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="Set the list's name"
+            />
+          </View>
+          <View style={s.pb10}>
+            <Text style={[styles.label, pal.text]}>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="Edit your list's 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 creating the mute list"
+            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/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx
new file mode 100644
index 000000000..a2775df9f
--- /dev/null
+++ b/src/view/com/modals/ListAddRemoveUser.tsx
@@ -0,0 +1,255 @@
+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="Toggle their inclusion in this list"
+            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}
+            accessibilityRole="button"
+            accessibilityLabel="Cancel this modal"
+            accessibilityHint=""
+            onAccessibilityEscape={onPressCancel}
+            label="Cancel"
+          />
+          <Button
+            testID="saveBtn"
+            type="primary"
+            onPress={onPressSave}
+            style={styles.footerBtn}
+            accessibilityRole="button"
+            accessibilityLabel="Save these 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/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index 628128e8f..a0b72a93f 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -65,7 +65,7 @@ export function TabBar({
     ],
   }
 
-  const onLayout = () => {
+  const onLayout = React.useCallback(() => {
     const promises = []
     for (let i = 0; i < items.length; i++) {
       promises.push(
@@ -86,14 +86,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/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index b4708cf53..1084fb6fc 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,6 +8,7 @@ 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'
@@ -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/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/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/index.ts b/src/view/index.ts
index dd8a585d6..b8a13f7f8 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -38,6 +38,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'
@@ -46,6 +48,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'
@@ -67,8 +70,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'
@@ -116,6 +121,8 @@ export function setup() {
     farEyeSlash,
     faGear,
     faGlobe,
+    faHand,
+    farHand,
     faHeart,
     fasHeart,
     faHouse,
@@ -124,6 +131,7 @@ export function setup() {
     faInfo,
     faLanguage,
     faLink,
+    faListUl,
     faLock,
     faMagnifyingGlass,
     faMessage,
@@ -145,8 +153,10 @@ export function setup() {
     faUser,
     faUsers,
     faUserCheck,
+    faUserSlash,
     faUserPlus,
     faUserXmark,
+    faUsersSlash,
     faTicket,
     faTrashCan,
     faX,
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 18e4f2506..0ead6b65c 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..29ef8b4b2
--- /dev/null
+++ b/src/view/screens/Moderation.tsx
@@ -0,0 +1,136 @@
+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}
+          accessibilityHint="Content filtering"
+          accessibilityLabel="Opens configurable content filtering settings">
+          <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 f7120051f..ec732f682 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 5fb212554..d23974859 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'
@@ -111,52 +115,81 @@ 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} />
+            )
           }
-          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} />
         }
         return <View />
       },
       [
         onPressTryAgain,
+        uiState.selectedView,
         uiState.profile.did,
         uiState.feed.isBlocking,
         uiState.feed.isBlockedBy,
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
new file mode 100644
index 000000000..a78faaf62
--- /dev/null
+++ b/src/view/screens/ProfileList.tsx
@@ -0,0 +1,175 @@
+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 {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])
+
+    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="Deletes the mutelist"
+              onPress={onPressDeleteList}
+            />
+          )}
+          {list?.isOwner && (
+            <Button
+              type="default"
+              label="Edit List"
+              testID="editListBtn"
+              accessibilityLabel="Edit list"
+              accessibilityHint="Opens a modal to edit the mutelist"
+              onPress={onPressEditList}
+            />
+          )}
+          {list.list?.viewer?.muted ? (
+            <Button
+              type="inverted"
+              label="Unsubscribe"
+              testID="unsubscribeListBtn"
+              accessibilityLabel="Unsubscribe from this list"
+              accessibilityHint="Stops muting the users included in this list"
+              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/Settings.tsx b/src/view/screens/Settings.tsx
index f98cdc0c8..c73653713 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>
@@ -288,54 +285,6 @@ export const SettingsScreen = withAuthRequired(
           <View style={styles.spacer20} />
 
           <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Moderation
-          </Text>
-          <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>
           <Link
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index bdd64807e..663a1bcf2 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -94,6 +94,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')
@@ -222,6 +228,19 @@ export const DrawerContent = observer(() => {
           />
           <MenuItem
             icon={
+              <FontAwesomeIcon
+                icon={['far', 'hand']}
+                style={pal.text as FontAwesomeIconStyle}
+                size={20}
+              />
+            }
+            label="Moderation"
+            accessibilityLabel="Moderation"
+            accessibilityHint=""
+            onPress={onPressModeration}
+          />
+          <MenuItem
+            icon={
               isAtMyProfile ? (
                 <UserIconSolid
                   style={pal.text as StyleProp<ViewStyle>}
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index ca6330304..37e79d347 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -203,6 +203,24 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         }
         label="Notifications"
       />
+      <NavItem
+        href="/moderation"
+        icon={
+          <FontAwesomeIcon
+            icon={['far', 'hand']}
+            style={pal.text as FontAwesomeIconStyle}
+            size={20}
+          />
+        }
+        iconFilled={
+          <FontAwesomeIcon
+            icon="hand"
+            style={pal.text as FontAwesomeIconStyle}
+            size={20}
+          />
+        }
+        label="Moderation"
+      />
       {store.session.hasSession && (
         <NavItem
           href={`/profile/${store.me.handle}`}