about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-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
38 files changed, 2045 insertions, 355 deletions
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,
   },
 })