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/auth/create/state.ts2
-rw-r--r--src/view/com/composer/Composer.tsx10
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx2
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx3
-rw-r--r--src/view/com/feeds/ProfileFeedgens.tsx23
-rw-r--r--src/view/com/lists/ListMembers.tsx18
-rw-r--r--src/view/com/lists/MyLists.tsx70
-rw-r--r--src/view/com/lists/ProfileLists.tsx23
-rw-r--r--src/view/com/modals/AltImage.tsx1
-rw-r--r--src/view/com/modals/Threadgate.tsx4
-rw-r--r--src/view/com/notifications/Feed.tsx14
-rw-r--r--src/view/com/notifications/FeedItem.tsx18
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx14
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx14
-rw-r--r--src/view/com/post-thread/PostThread.tsx11
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx9
-rw-r--r--src/view/com/post/Post.tsx3
-rw-r--r--src/view/com/posts/Feed.tsx22
-rw-r--r--src/view/com/posts/FeedItem.tsx15
-rw-r--r--src/view/com/profile/ProfileCard.tsx8
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx14
-rw-r--r--src/view/com/profile/ProfileFollows.tsx14
-rw-r--r--src/view/com/util/Html.tsx4
-rw-r--r--src/view/com/util/List.tsx45
-rw-r--r--src/view/com/util/PostMeta.tsx4
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx241
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx29
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx3
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx18
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx251
-rw-r--r--src/view/com/util/post-embeds/YoutubeEmbed.tsx55
-rw-r--r--src/view/com/util/post-embeds/index.tsx18
32 files changed, 730 insertions, 250 deletions
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index 4df82f8fc..a77d2a44f 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -144,7 +144,7 @@ export async function submit({
 }
 
 export function is13(state: CreateAccountState) {
-  return getAge(state.birthDate) >= 18
+  return getAge(state.birthDate) >= 13
 }
 
 export function is18(state: CreateAccountState) {
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index c4453e0c3..9f60923d6 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -207,7 +207,11 @@ export const ComposePost = observer(function ComposePost({
     setError('')
 
     if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) {
-      setError('Did you want to say anything?')
+      setError(_(msg`Did you want to say anything?`))
+      return
+    }
+    if (extLink?.isLoading) {
+      setError(_(msg`Please wait for your link card to finish loading`))
       return
     }
 
@@ -438,7 +442,7 @@ export const ComposePost = observer(function ComposePost({
                   accessibilityLabel={_(msg`Add link card`)}
                   accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
                   <Text style={pal.text}>
-                    <Trans>Add link card:</Trans>
+                    <Trans>Add link card:</Trans>{' '}
                     <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
                   </Text>
                 </TouchableOpacity>
@@ -452,7 +456,7 @@ export const ComposePost = observer(function ComposePost({
               <OpenCameraBtn gallery={gallery} />
             </>
           ) : null}
-          {isDesktop ? <EmojiPickerButton /> : null}
+          {!isMobile ? <EmojiPickerButton /> : null}
           <View style={s.flex1} />
           <SelectLangBtn />
           <CharProgress count={graphemeLength} />
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index 1f7412561..51197b8e4 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -134,7 +134,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
           return true
         }
 
-        if (event.key === 'Enter') {
+        if (event.key === 'Enter' || event.key === 'Tab') {
           enterHandler()
           return true
         }
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 09a2dcf41..f4b2d99b0 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -76,7 +76,7 @@ export function EmojiPicker({close}: {close: () => void}) {
                 return (await import('./EmojiPickerData.json')).default
               }}
               onEmojiSelect={onInsert}
-              autoFocus={false}
+              autoFocus={true}
             />
           </View>
         </TouchableWithoutFeedback>
@@ -96,6 +96,7 @@ const styles = StyleSheet.create({
   },
   trigger: {
     backgroundColor: 'transparent',
+    // @ts-ignore web only -prf
     border: 'none',
     paddingTop: 4,
     paddingLeft: 12,
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index ff6505501..8665fbfac 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -1,12 +1,5 @@
 import React from 'react'
-import {
-  Dimensions,
-  RefreshControl,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
+import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 import {List, ListRef} from '../util/List'
 import {FeedSourceCardLoaded} from './FeedSourceCard'
@@ -180,22 +173,14 @@ export const ProfileFeedgens = React.forwardRef<
         data={items}
         keyExtractor={(item: any) => item._reactKey || item.uri}
         renderItem={renderItemInner}
-        refreshControl={
-          <RefreshControl
-            refreshing={isPTRing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-            progressViewOffset={headerOffset}
-          />
-        }
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
+        headerOffset={headerOffset}
         contentContainerStyle={{
           minHeight: Dimensions.get('window').height * 1.5,
         }}
-        style={{paddingTop: headerOffset}}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         removeClippedSubviews={true}
-        contentOffset={{x: 0, y: headerOffset * -1}}
         // @ts-ignore our .web version only -prf
         desktopFixedHeight
         onEndReached={onEndReached}
diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx
index a31ca4793..932f4b512 100644
--- a/src/view/com/lists/ListMembers.tsx
+++ b/src/view/com/lists/ListMembers.tsx
@@ -2,7 +2,6 @@ import React from 'react'
 import {
   ActivityIndicator,
   Dimensions,
-  RefreshControl,
   StyleProp,
   View,
   ViewStyle,
@@ -15,7 +14,6 @@ import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {ProfileCard} from '../profile/ProfileCard'
 import {Button} from '../util/forms/Button'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useListMembersQuery} from '#/state/queries/list-members'
 import {logger} from '#/logger'
@@ -51,7 +49,6 @@ export function ListMembers({
   headerOffset?: number
   desktopFixedHeightOffset?: number
 }) {
-  const pal = usePalette('default')
   const {track} = useAnalytics()
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const {isMobile} = useWebMediaQueries()
@@ -183,6 +180,7 @@ export function ListMembers({
           profile={(item as AppBskyGraphDefs.ListItemView).subject}
           renderButton={renderMemberButton}
           style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
+          noModFilter
         />
       )
     },
@@ -215,24 +213,16 @@ export function ListMembers({
         renderItem={renderItem}
         ListHeaderComponent={renderHeader}
         ListFooterComponent={Footer}
-        refreshControl={
-          <RefreshControl
-            refreshing={isRefreshing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-            progressViewOffset={headerOffset}
-          />
-        }
+        refreshing={isRefreshing}
+        onRefresh={onRefresh}
+        headerOffset={headerOffset}
         contentContainerStyle={{
           minHeight: Dimensions.get('window').height * 1.5,
         }}
-        style={{paddingTop: headerOffset}}
         onScrolledDownChange={onScrolledDownChange}
         onEndReached={onEndReached}
         onEndReachedThreshold={0.6}
         removeClippedSubviews={true}
-        contentOffset={{x: 0, y: headerOffset * -1}}
         // @ts-ignore our .web version only -prf
         desktopFixedHeight={desktopFixedHeightOffset || true}
       />
diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx
index 586ad234e..a2a6b0651 100644
--- a/src/view/com/lists/MyLists.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -119,31 +119,51 @@ export function MyLists({
     [error, onRefresh, renderItem, pal],
   )
 
-  const FlatListCom = inline ? RNFlatList : List
-  return (
-    <View testID={testID} style={style}>
-      {items.length > 0 && (
-        <FlatListCom
-          testID={testID ? `${testID}-flatlist` : undefined}
-          data={items}
-          keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
-          renderItem={renderItemInner}
-          refreshControl={
-            <RefreshControl
-              refreshing={isPTRing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-            />
-          }
-          contentContainerStyle={[s.contentContainer]}
-          removeClippedSubviews={true}
-          // @ts-ignore our .web version only -prf
-          desktopFixedHeight
-        />
-      )}
-    </View>
-  )
+  if (inline) {
+    return (
+      <View testID={testID} style={style}>
+        {items.length > 0 && (
+          <RNFlatList
+            testID={testID ? `${testID}-flatlist` : undefined}
+            data={items}
+            keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
+            renderItem={renderItemInner}
+            refreshControl={
+              <RefreshControl
+                refreshing={isPTRing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+              />
+            }
+            contentContainerStyle={[s.contentContainer]}
+            removeClippedSubviews={true}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  } else {
+    return (
+      <View testID={testID} style={style}>
+        {items.length > 0 && (
+          <List
+            testID={testID ? `${testID}-flatlist` : undefined}
+            data={items}
+            keyExtractor={item => (item.uri ? item.uri : item._reactKey)}
+            renderItem={renderItemInner}
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            contentContainerStyle={[s.contentContainer]}
+            removeClippedSubviews={true}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  }
 }
 
 const styles = StyleSheet.create({
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index e3d9bd0b4..db981717f 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -1,12 +1,5 @@
 import React from 'react'
-import {
-  Dimensions,
-  RefreshControl,
-  StyleProp,
-  StyleSheet,
-  View,
-  ViewStyle,
-} from 'react-native'
+import {Dimensions, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {useQueryClient} from '@tanstack/react-query'
 import {List, ListRef} from '../util/List'
 import {ListCard} from './ListCard'
@@ -182,22 +175,14 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
           data={items}
           keyExtractor={(item: any) => item._reactKey}
           renderItem={renderItemInner}
-          refreshControl={
-            <RefreshControl
-              refreshing={isPTRing}
-              onRefresh={onRefresh}
-              tintColor={pal.colors.text}
-              titleColor={pal.colors.text}
-              progressViewOffset={headerOffset}
-            />
-          }
+          refreshing={isPTRing}
+          onRefresh={onRefresh}
+          headerOffset={headerOffset}
           contentContainerStyle={{
             minHeight: Dimensions.get('window').height * 1.5,
           }}
-          style={{paddingTop: headerOffset}}
           indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
           removeClippedSubviews={true}
-          contentOffset={{x: 0, y: headerOffset * -1}}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
           onEndReached={onEndReached}
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
index 80130f43a..a2e918317 100644
--- a/src/view/com/modals/AltImage.tsx
+++ b/src/view/com/modals/AltImage.tsx
@@ -80,6 +80,7 @@ export function Component({image}: Props) {
               source={{
                 uri: image.cropped?.path ?? image.path,
               }}
+              contentFit="contain"
               accessible={true}
               accessibilityIgnoresInvertColors
             />
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index 9d78a2e6d..0deef185b 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -69,7 +69,7 @@ export function Component({
 
       <ScrollView>
         <Text style={[pal.text, styles.description]}>
-          Choose "Everybody" or "Nobody"
+          <Trans>Choose "Everybody" or "Nobody"</Trans>
         </Text>
         <View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}>
           <Selectable
@@ -86,7 +86,7 @@ export function Component({
           />
         </View>
         <Text style={[pal.text, styles.description]}>
-          Or combine these options:
+          <Trans>Or combine these options:</Trans>
         </Text>
         <View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}>
           <Selectable
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 52d534c4f..a99fe2c1d 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -1,13 +1,12 @@
 import React from 'react'
 import {CenteredView} from '../util/Views'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {FeedItem} from './FeedItem'
 import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {EmptyState} from '../util/EmptyState'
 import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
 import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
 import {logger} from '#/logger'
@@ -30,7 +29,6 @@ export function Feed({
   onScrolledDownChange: (isScrolledDown: boolean) => void
   ListHeaderComponent?: () => JSX.Element
 }) {
-  const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
 
   const moderationOpts = useModerationOpts()
@@ -152,14 +150,8 @@ export function Feed({
         renderItem={renderItem}
         ListHeaderComponent={ListHeaderComponent}
         ListFooterComponent={FeedFooter}
-        refreshControl={
-          <RefreshControl
-            refreshing={isPTRing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-          />
-        }
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
         onEndReached={onEndReached}
         onEndReachedThreshold={0.6}
         onScrolledDownChange={onScrolledDownChange}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index aaa2ea2c6..24b7e4fb6 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -42,6 +42,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
 import {isWeb} from 'platform/detection'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {FeedSourceCard} from '../feeds/FeedSourceCard'
 
 const MAX_AUTHORS = 5
 
@@ -112,7 +113,7 @@ let FeedItem = ({
     ]
   }, [item, moderationOpts])
 
-  if (item.subjectUri && !item.subject) {
+  if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') {
     // don't render anything if the target post was deleted or unfindable
     return <View />
   }
@@ -166,7 +167,7 @@ let FeedItem = ({
     iconStyle = [s.blue3 as FontAwesomeIconStyle]
   } else if (item.type === 'feedgen-like') {
     action = `liked your custom feed${
-      item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
+      item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}'` : ''
     }`
     icon = 'HeartIconSolid'
     iconStyle = [
@@ -256,6 +257,13 @@ let FeedItem = ({
         {item.type === 'post-like' || item.type === 'repost' ? (
           <AdditionalPostText post={item.subject} />
         ) : null}
+        {item.type === 'feedgen-like' && item.subjectUri ? (
+          <FeedSourceCard
+            feedUri={item.subjectUri}
+            style={[pal.view, pal.border, styles.feedcard]}
+            showLikes
+          />
+        ) : null}
       </View>
     </Link>
   )
@@ -496,6 +504,12 @@ const styles = StyleSheet.create({
     marginLeft: 2,
     opacity: 0.8,
   },
+  feedcard: {
+    borderWidth: 1,
+    borderRadius: 8,
+    paddingVertical: 12,
+    marginTop: 6,
+  },
 
   addedContainer: {
     paddingTop: 4,
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 245ba59e8..6e013f611 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,18 +1,16 @@
 import React, {useCallback, useMemo, useState} from 'react'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
 import {CenteredView} from '../util/Views'
 import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
 import {cleanError} from '#/lib/strings/errors'
 
 export function PostLikedBy({uri}: {uri: string}) {
-  const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = useState(false)
   const {
     data: resolvedUri,
@@ -88,14 +86,8 @@ export function PostLikedBy({uri}: {uri: string}) {
     <List
       data={likes}
       keyExtractor={item => item.actor.did}
-      refreshControl={
-        <RefreshControl
-          refreshing={isPTRing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
       onEndReached={onEndReached}
       renderItem={renderItem}
       initialNumToRender={15}
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 5cc006388..a2d3be558 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,18 +1,16 @@
 import React, {useMemo, useCallback, useState} from 'react'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView} from '../util/Views'
 import {List} from '../util/List'
 import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {usePalette} from 'lib/hooks/usePalette'
 import {logger} from '#/logger'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
 import {cleanError} from '#/lib/strings/errors'
 
 export function PostRepostedBy({uri}: {uri: string}) {
-  const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = useState(false)
   const {
     data: resolvedUri,
@@ -89,14 +87,8 @@ export function PostRepostedBy({uri}: {uri: string}) {
     <List
       data={repostedBy}
       keyExtractor={item => item.did}
-      refreshControl={
-        <RefreshControl
-          refreshing={isPTRing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
       onEndReached={onEndReached}
       renderItem={renderItem}
       initialNumToRender={15}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 917550884..6cd1f3551 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -2,7 +2,6 @@ import React, {useEffect, useRef} from 'react'
 import {
   ActivityIndicator,
   Pressable,
-  RefreshControl,
   StyleSheet,
   TouchableOpacity,
   View,
@@ -349,14 +348,8 @@ function PostThreadLoaded({
       }
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
-      refreshControl={
-        <RefreshControl
-          refreshing={isPTRing}
-          onRefresh={onPTR}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
+      refreshing={isPTRing}
+      onRefresh={onPTR}
       onContentSizeChange={onContentSizeChange}
       style={s.hContentRegion}
       // @ts-ignore our .web version only -prf
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 2ff803071..986fd70b2 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -5,9 +5,9 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   RichText as RichTextAPI,
-  moderatePost,
   PostModeration,
 } from '@atproto/api'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
@@ -186,9 +186,9 @@ let PostThreadItemLoaded = ({
     return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
   }, [post.uri, post.author])
   const repostsTitle = 'Reposts of this post'
-  const isSelfLabeledPost =
+  const isModeratedPost =
     moderation.decisions.post.cause?.type === 'label' &&
-    moderation.decisions.post.cause.label.src === currentAccount?.did
+    moderation.decisions.post.cause.label.src !== currentAccount?.did
 
   const translatorUrl = getTranslatorLink(
     record?.text || '',
@@ -335,7 +335,7 @@ let PostThreadItemLoaded = ({
               postUri={post.uri}
               record={record}
               showAppealLabelItem={
-                post.author.did === currentAccount?.did && !isSelfLabeledPost
+                post.author.did === currentAccount?.did && isModeratedPost
               }
               style={{
                 paddingVertical: 6,
@@ -539,6 +539,7 @@ let PostThreadItemLoaded = ({
                   timestamp={post.indexedAt}
                   postHref={postHref}
                   showAvatar={isThreadedChild}
+                  avatarModeration={moderation.avatar}
                   avatarSize={28}
                   displayNameType="md-bold"
                   displayNameStyle={isThreadedChild && s.ml2}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 9b1bf7a49..fca4171c3 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -4,10 +4,10 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
-  moderatePost,
   PostModeration,
   RichText as RichTextAPI,
 } from '@atproto/api'
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {Link, TextLink} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
@@ -221,6 +221,7 @@ const styles = StyleSheet.create({
     paddingBottom: 5,
     paddingLeft: 10,
     borderTopWidth: 1,
+    // @ts-ignore web only -prf
     cursor: 'pointer',
   },
   layout: {
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 8d5c11bda..02a3537eb 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -3,7 +3,6 @@ import {
   ActivityIndicator,
   AppState,
   Dimensions,
-  RefreshControl,
   StyleProp,
   StyleSheet,
   View,
@@ -16,7 +15,6 @@ import {FeedErrorMessage} from './FeedErrorMessage'
 import {FeedSlice} from './FeedSlice'
 import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {logger} from '#/logger'
 import {
@@ -74,7 +72,6 @@ let Feed = ({
   ListHeaderComponent?: () => JSX.Element
   extraData?: any
 }): React.ReactNode => {
-  const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
   const queryClient = useQueryClient()
@@ -98,10 +95,13 @@ let Feed = ({
     isFetchingNextPage,
     fetchNextPage,
   } = usePostFeedQuery(feed, feedParams, opts)
-  const isEmpty = !isFetching && !data?.pages[0]?.slices.length
   if (data?.pages[0]) {
     lastFetchRef.current = data?.pages[0].fetchedAt
   }
+  const isEmpty = React.useMemo(
+    () => !isFetching && !data?.pages?.some(page => page.slices.length),
+    [isFetching, data],
+  )
 
   const checkForNew = React.useCallback(async () => {
     if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
@@ -294,25 +294,17 @@ let Feed = ({
         renderItem={renderItem}
         ListFooterComponent={FeedFooter}
         ListHeaderComponent={ListHeaderComponent}
-        refreshControl={
-          <RefreshControl
-            refreshing={isPTRing}
-            onRefresh={onRefresh}
-            tintColor={pal.colors.text}
-            titleColor={pal.colors.text}
-            progressViewOffset={headerOffset}
-          />
-        }
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
+        headerOffset={headerOffset}
         contentContainerStyle={{
           minHeight: Dimensions.get('window').height * 1.5,
         }}
-        style={{paddingTop: headerOffset}}
         onScrolledDownChange={onScrolledDownChange}
         indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
         onEndReached={onEndReached}
         onEndReachedThreshold={2} // number of posts left to trigger load more
         removeClippedSubviews={true}
-        contentOffset={{x: 0, y: headerOffset * -1}}
         extraData={extraData}
         // @ts-ignore our .web version only -prf
         desktopFixedHeight={
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 20d199745..942d7bf71 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -34,6 +34,7 @@ import {countLines} from 'lib/strings/helpers'
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {FeedNameText} from '../util/FeedInfoText'
+import {useSession} from '#/state/session'
 
 export function FeedItem({
   post,
@@ -102,10 +103,14 @@ let FeedItemInner = ({
 }): React.ReactNode => {
   const {openComposer} = useComposerControls()
   const pal = usePalette('default')
+  const {currentAccount} = useSession()
   const href = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
+  const isModeratedPost =
+    moderation.decisions.post.cause?.type === 'label' &&
+    moderation.decisions.post.cause.label.src !== currentAccount?.did
 
   const replyAuthorDid = useMemo(() => {
     if (!record?.reply) {
@@ -284,7 +289,14 @@ let FeedItemInner = ({
             postEmbed={post.embed}
             postAuthor={post.author}
           />
-          <PostCtrls post={post} record={record} onPressReply={onPressReply} />
+          <PostCtrls
+            post={post}
+            record={record}
+            onPressReply={onPressReply}
+            showAppealLabelItem={
+              post.author.did === currentAccount?.did && isModeratedPost
+            }
+          />
         </View>
       </View>
     </Link>
@@ -364,6 +376,7 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
     paddingLeft: 10,
     paddingRight: 15,
+    // @ts-ignore web only -prf
     cursor: 'pointer',
     overflow: 'hidden',
   },
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index c5b2dc528..ef95f5924 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -27,6 +27,7 @@ import {useSession} from '#/state/session'
 export function ProfileCard({
   testID,
   profile: profileUnshadowed,
+  noModFilter,
   noBg,
   noBorder,
   followers,
@@ -35,6 +36,7 @@ export function ProfileCard({
 }: {
   testID?: string
   profile: AppBskyActorDefs.ProfileViewBasic
+  noModFilter?: boolean
   noBg?: boolean
   noBorder?: boolean
   followers?: AppBskyActorDefs.ProfileView[] | undefined
@@ -50,7 +52,11 @@ export function ProfileCard({
     return null
   }
   const moderation = moderateProfile(profile, moderationOpts)
-  if (moderation.account.filter) {
+  if (
+    !noModFilter &&
+    moderation.account.filter &&
+    moderation.account.cause?.type !== 'muted'
+  ) {
     return null
   }
 
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 077dabe53..fd8dee173 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -1,18 +1,16 @@
 import React from 'react'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView} from '../util/Views'
 import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
 import {cleanError} from '#/lib/strings/errors'
 
 export function ProfileFollowers({name}: {name: string}) {
-  const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
   const {
     data: resolvedDid,
@@ -90,14 +88,8 @@ export function ProfileFollowers({name}: {name: string}) {
     <List
       data={followers}
       keyExtractor={item => item.did}
-      refreshControl={
-        <RefreshControl
-          refreshing={isPTRing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
       onEndReached={onEndReached}
       renderItem={renderItem}
       initialNumToRender={15}
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index 5265ee07e..091922dd9 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -1,18 +1,16 @@
 import React from 'react'
-import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
 import {CenteredView} from '../util/Views'
 import {List} from '../util/List'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {ProfileCardWithFollowBtn} from './ProfileCard'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {logger} from '#/logger'
 import {cleanError} from '#/lib/strings/errors'
 
 export function ProfileFollows({name}: {name: string}) {
-  const pal = usePalette('default')
   const [isPTRing, setIsPTRing] = React.useState(false)
   const {
     data: resolvedDid,
@@ -90,14 +88,8 @@ export function ProfileFollows({name}: {name: string}) {
     <List
       data={follows}
       keyExtractor={item => item.did}
-      refreshControl={
-        <RefreshControl
-          refreshing={isPTRing}
-          onRefresh={onRefresh}
-          tintColor={pal.colors.text}
-          titleColor={pal.colors.text}
-        />
-      }
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
       onEndReached={onEndReached}
       renderItem={renderItem}
       initialNumToRender={15}
diff --git a/src/view/com/util/Html.tsx b/src/view/com/util/Html.tsx
index 1590955a2..2e4719481 100644
--- a/src/view/com/util/Html.tsx
+++ b/src/view/com/util/Html.tsx
@@ -30,6 +30,7 @@ export function H1({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-xl']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
 }
 
@@ -37,6 +38,7 @@ export function H2({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-lg']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
 }
 
@@ -44,6 +46,7 @@ export function H3({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography.title
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
 }
 
@@ -51,6 +54,7 @@ export function H4({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-sm']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
 }
 
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 2acc3f4b3..9abd7d35a 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,27 +1,42 @@
 import React, {memo, startTransition} from 'react'
-import {FlatListProps} from 'react-native'
+import {FlatListProps, RefreshControl} from 'react-native'
 import {FlatList_INTERNAL} from './Views'
+import {addStyle} from 'lib/styles'
 import {useScrollHandlers} from '#/lib/ScrollContext'
 import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {usePalette} from '#/lib/hooks/usePalette'
 
 export type ListMethods = FlatList_INTERNAL
 export type ListProps<ItemT> = Omit<
   FlatListProps<ItemT>,
-  'onScroll' // Use ScrollContext instead.
+  | 'onScroll' // Use ScrollContext instead.
+  | 'refreshControl' // Pass refreshing and/or onRefresh instead.
+  | 'contentOffset' // Pass headerOffset instead.
 > & {
   onScrolledDownChange?: (isScrolledDown: boolean) => void
+  headerOffset?: number
+  refreshing?: boolean
+  onRefresh?: () => void
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
 const SCROLLED_DOWN_LIMIT = 200
 
 function ListImpl<ItemT>(
-  {onScrolledDownChange, ...props}: ListProps<ItemT>,
+  {
+    onScrolledDownChange,
+    refreshing,
+    onRefresh,
+    headerOffset,
+    style,
+    ...props
+  }: ListProps<ItemT>,
   ref: React.Ref<ListMethods>,
 ) {
   const isScrolledDown = useSharedValue(false)
   const contextScrollHandlers = useScrollHandlers()
+  const pal = usePalette('default')
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     startTransition(() => {
@@ -49,12 +64,36 @@ function ListImpl<ItemT>(
     },
   })
 
+  let refreshControl
+  if (refreshing !== undefined || onRefresh !== undefined) {
+    refreshControl = (
+      <RefreshControl
+        refreshing={refreshing ?? false}
+        onRefresh={onRefresh}
+        tintColor={pal.colors.text}
+        titleColor={pal.colors.text}
+        progressViewOffset={headerOffset}
+      />
+    )
+  }
+
+  let contentOffset
+  if (headerOffset != null) {
+    style = addStyle(style, {
+      paddingTop: headerOffset,
+    })
+    contentOffset = {x: 0, y: headerOffset * -1}
+  }
+
   return (
     <FlatList_INTERNAL
       {...props}
       scrollIndicatorInsets={{right: 1}}
+      contentOffset={contentOffset}
+      refreshControl={refreshControl}
       onScroll={scrollHandler}
       scrollEventThrottle={1}
+      style={style}
       ref={ref}
     />
   )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index eef7094cd..b9c3842b3 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {isAndroid} from 'platform/detection'
 import {TimeElapsed} from './TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
+import {ModerationUI} from '@atproto/api'
 
 interface PostMetaOpts {
   author: {
@@ -23,6 +24,7 @@ interface PostMetaOpts {
   postHref: string
   timestamp: string
   showAvatar?: boolean
+  avatarModeration?: ModerationUI
   avatarSize?: number
   displayNameType?: TypographyVariant
   displayNameStyle?: StyleProp<TextStyle>
@@ -41,7 +43,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
           <UserAvatar
             avatar={opts.author.avatar}
             size={opts.avatarSize || 16}
-            // TODO moderation
+            moderation={opts.avatarModeration}
           />
         </View>
       )}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
new file mode 100644
index 000000000..9e9888ad8
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -0,0 +1,241 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {Pressable, StyleSheet, View, Text} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuContent = DropdownMenu.Content
+
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
+  const theme = useTheme()
+  const [focused, setFocused] = React.useState(false)
+  const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
+
+  return (
+    <DropdownMenu.Item
+      {...props}
+      style={StyleSheet.flatten([
+        styles.item,
+        focused && {backgroundColor: backgroundColor},
+      ])}
+      onFocus={() => {
+        setFocused(true)
+      }}
+      onBlur={() => {
+        setFocused(false)
+      }}
+    />
+  )
+}
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+  label: string | 'separator'
+  onPress?: () => void
+  testID?: string
+  icon?: {
+    ios: MenuItemCommonProps['ios']
+    android: string
+    web: IconProp
+  }
+}
+type Props = {
+  items: DropdownItem[]
+  testID?: string
+  accessibilityLabel?: string
+  accessibilityHint?: string
+}
+
+export function NativeDropdown({
+  items,
+  children,
+  testID,
+  accessibilityLabel,
+  accessibilityHint,
+}: React.PropsWithChildren<Props>) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+  const [open, setOpen] = React.useState(false)
+  const buttonRef = React.useRef<HTMLButtonElement>(null)
+  const menuRef = React.useRef<HTMLDivElement>(null)
+  const {borderColor: separatorColor} =
+    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+  React.useEffect(() => {
+    function clickHandler(e: MouseEvent) {
+      const t = e.target
+
+      if (!open) return
+      if (!t) return
+      if (!buttonRef.current || !menuRef.current) return
+
+      if (
+        t !== buttonRef.current &&
+        !buttonRef.current.contains(t as Node) &&
+        t !== menuRef.current &&
+        !menuRef.current.contains(t as Node)
+      ) {
+        // prevent clicking through to links beneath dropdown
+        // only applies to mobile web
+        e.preventDefault()
+        e.stopPropagation()
+
+        // close menu
+        setOpen(false)
+      }
+    }
+
+    function keydownHandler(e: KeyboardEvent) {
+      if (e.key === 'Escape' && open) {
+        setOpen(false)
+      }
+    }
+
+    document.addEventListener('click', clickHandler, true)
+    window.addEventListener('keydown', keydownHandler, true)
+    return () => {
+      document.removeEventListener('click', clickHandler, true)
+      window.removeEventListener('keydown', keydownHandler, true)
+    }
+  }, [open, setOpen])
+
+  return (
+    <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
+      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+        <Pressable
+          ref={buttonRef as unknown as React.Ref<View>}
+          testID={testID}
+          accessibilityRole="button"
+          accessibilityLabel={accessibilityLabel}
+          accessibilityHint={accessibilityHint}
+          onPress={() => setOpen(o => !o)}
+          hitSlop={HITSLOP_10}>
+          {children}
+        </Pressable>
+      </DropdownMenu.Trigger>
+
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content
+          ref={menuRef}
+          style={
+            StyleSheet.flatten([
+              styles.content,
+              dropDownBackgroundColor,
+            ]) as React.CSSProperties
+          }
+          loop>
+          {items.map((item, index) => {
+            if (item.label === 'separator') {
+              return (
+                <DropdownMenu.Separator
+                  key={getKey(item.label, index, item.testID)}
+                  style={
+                    StyleSheet.flatten([
+                      styles.separator,
+                      {backgroundColor: separatorColor},
+                    ]) as React.CSSProperties
+                  }
+                />
+              )
+            }
+            if (index > 1 && items[index - 1].label === 'separator') {
+              return (
+                <DropdownMenu.Group
+                  key={getKey(item.label, index, item.testID)}>
+                  <DropdownMenuItem
+                    key={getKey(item.label, index, item.testID)}
+                    onSelect={item.onPress}>
+                    <Text
+                      selectable={false}
+                      style={[pal.text, styles.itemTitle]}>
+                      {item.label}
+                    </Text>
+                    {item.icon && (
+                      <FontAwesomeIcon
+                        icon={item.icon.web}
+                        size={20}
+                        color={pal.colors.textLight}
+                      />
+                    )}
+                  </DropdownMenuItem>
+                </DropdownMenu.Group>
+              )
+            }
+            return (
+              <DropdownMenuItem
+                key={getKey(item.label, index, item.testID)}
+                onSelect={item.onPress}>
+                <Text selectable={false} style={[pal.text, styles.itemTitle]}>
+                  {item.label}
+                </Text>
+                {item.icon && (
+                  <FontAwesomeIcon
+                    icon={item.icon.web}
+                    size={20}
+                    color={pal.colors.textLight}
+                  />
+                )}
+              </DropdownMenuItem>
+            )
+          })}
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+  if (id) {
+    return id
+  }
+  return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+  separator: {
+    height: 1,
+    marginTop: 4,
+    marginBottom: 4,
+  },
+  content: {
+    backgroundColor: '#f0f0f0',
+    borderRadius: 8,
+    paddingTop: 4,
+    paddingBottom: 4,
+    paddingLeft: 4,
+    paddingRight: 4,
+    marginTop: 6,
+
+    // @ts-ignore web only -prf
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+  },
+  item: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    columnGap: 20,
+    // @ts-ignore -web
+    cursor: 'pointer',
+    paddingTop: 8,
+    paddingBottom: 8,
+    paddingLeft: 12,
+    paddingRight: 12,
+    borderRadius: 8,
+  },
+  itemTitle: {
+    fontSize: 16,
+    fontWeight: '500',
+    paddingRight: 10,
+  },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 193bb9bd7..1f2e067c2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -18,6 +18,7 @@ import {getTranslatorLink} from '#/locale/helpers'
 import {usePostDeleteMutation} from '#/state/queries/post'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
+import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
 import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -50,9 +51,12 @@ let PostDropdownBtn = ({
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
   const postDeleteMutation = usePostDeleteMutation()
+  const hiddenPosts = useHiddenPosts()
+  const {hidePost} = useHiddenPostsApi()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
+  const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
   const isAuthor = postAuthor.did === currentAccount?.did
   const href = React.useMemo(() => {
     const urip = new AtUri(postUri)
@@ -98,6 +102,10 @@ let PostDropdownBtn = ({
     Linking.openURL(translatorUrl)
   }, [translatorUrl])
 
+  const onHidePost = React.useCallback(() => {
+    hidePost({uri: postUri})
+  }, [postUri, hidePost])
+
   const dropdownItems: NativeDropdownItem[] = [
     {
       label: _(msg`Translate`),
@@ -159,6 +167,27 @@ let PostDropdownBtn = ({
         web: 'comment-slash',
       },
     },
+    hasSession &&
+      !isAuthor &&
+      !isPostHidden && {
+        label: _(msg`Hide post`),
+        onPress() {
+          openModal({
+            name: 'confirm',
+            title: _(msg`Hide this post?`),
+            message: _(msg`This will hide this post from your feeds.`),
+            onPressConfirm: onHidePost,
+          })
+        },
+        testID: 'postDropdownHideBtn',
+        icon: {
+          ios: {
+            name: 'eye.slash',
+          },
+          android: 'ic_menu_delete',
+          web: ['far', 'eye-slash'],
+        },
+      },
     {
       label: 'separator',
     },
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 414fb1e09..a50b52175 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -31,12 +31,14 @@ let PostCtrls = ({
   big,
   post,
   record,
+  showAppealLabelItem,
   style,
   onPressReply,
 }: {
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
+  showAppealLabelItem?: boolean
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
 }): React.ReactNode => {
@@ -207,6 +209,7 @@ let PostCtrls = ({
           postCid={post.cid}
           postUri={post.uri}
           record={record}
+          showAppealLabelItem={showAppealLabelItem}
           style={styles.ctrlPad}
         />
       )}
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index d5bb38fb2..27aa804d3 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -6,22 +6,28 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {toNiceDomain} from 'lib/strings/url-helpers'
+import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
+import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
 
 export const ExternalLinkEmbed = ({
   link,
-  imageChild,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
-  imageChild?: React.ReactNode
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+
+  const embedPlayerParams = React.useMemo(
+    () => parseEmbedPlayerFromUrl(link.uri),
+    [link.uri],
+  )
+
   return (
     <View
       style={{
-        flexDirection: isMobile ? 'column' : 'row',
+        flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
       }}>
-      {link.thumb ? (
+      {link.thumb && !embedPlayerParams ? (
         <View
           style={
             !isMobile
@@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({
             source={{uri: link.thumb}}
             accessibilityIgnoresInvertColors
           />
-          {imageChild}
         </View>
       ) : undefined}
+      {embedPlayerParams && (
+        <ExternalPlayer link={link} params={embedPlayerParams} />
+      )}
       <View
         style={{
           paddingHorizontal: isMobile ? 10 : 14,
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
new file mode 100644
index 000000000..580cf363a
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -0,0 +1,251 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  Dimensions,
+  GestureResponderEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {Image} from 'expo-image'
+import {WebView} from 'react-native-webview'
+import YoutubePlayer from 'react-native-youtube-iframe'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
+import {EventStopper} from '../EventStopper'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {isNative} from 'platform/detection'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
+
+interface ShouldStartLoadRequest {
+  url: string
+}
+
+// This renders the overlay when the player is either inactive or loading as a separate layer
+function PlaceholderOverlay({
+  isLoading,
+  isPlayerActive,
+  onPress,
+}: {
+  isLoading: boolean
+  isPlayerActive: boolean
+  onPress: (event: GestureResponderEvent) => void
+}) {
+  // If the player is active and not loading, we don't want to show the overlay.
+  if (isPlayerActive && !isLoading) return null
+
+  return (
+    <View style={[styles.layer, styles.overlayLayer]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Play Video"
+        accessibilityHint=""
+        onPress={onPress}
+        style={[styles.overlayContainer, styles.topRadius]}>
+        {!isPlayerActive ? (
+          <FontAwesomeIcon icon="play" size={42} color="white" />
+        ) : (
+          <ActivityIndicator size="large" color="white" />
+        )}
+      </Pressable>
+    </View>
+  )
+}
+
+// This renders the webview/youtube player as a separate layer
+function Player({
+  height,
+  params,
+  onLoad,
+  isPlayerActive,
+}: {
+  isPlayerActive: boolean
+  params: EmbedPlayerParams
+  height: number
+  onLoad: () => void
+}) {
+  // ensures we only load what's requested
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) => event.url === params.playerUri,
+    [params.playerUri],
+  )
+
+  // Don't show the player until it is active
+  if (!isPlayerActive) return null
+
+  return (
+    <View style={[styles.layer, styles.playerLayer]}>
+      <EventStopper>
+        {isNative && params.type === 'youtube_video' ? (
+          <YoutubePlayer
+            videoId={params.videoId}
+            play
+            height={height}
+            onReady={onLoad}
+            webViewStyle={[styles.webview, styles.topRadius]}
+          />
+        ) : (
+          <View style={{height, width: '100%'}}>
+            <WebView
+              javaScriptEnabled={true}
+              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+              mediaPlaybackRequiresUserAction={false}
+              allowsInlineMediaPlayback
+              bounces={false}
+              allowsFullscreenVideo
+              nestedScrollEnabled
+              source={{uri: params.playerUri}}
+              onLoad={onLoad}
+              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+              style={[styles.webview, styles.topRadius]}
+            />
+          </View>
+        )}
+      </EventStopper>
+    </View>
+  )
+}
+
+// This renders the player area and handles the logic for when to show the player and when to show the overlay
+export function ExternalPlayer({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const navigation = useNavigation<NavigationProp>()
+
+  const [isPlayerActive, setPlayerActive] = React.useState(false)
+  const [isLoading, setIsLoading] = React.useState(true)
+  const [dim, setDim] = React.useState({
+    width: 0,
+    height: 0,
+  })
+
+  const viewRef = React.useRef<View>(null)
+
+  // watch for leaving the viewport due to scrolling
+  React.useEffect(() => {
+    // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
+    // continue playing. We need to watch for the blur event
+    const unsubscribe = navigation.addListener('blur', () => {
+      setPlayerActive(false)
+    })
+
+    const interval = setInterval(() => {
+      viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
+        const window = Dimensions.get('window')
+        const top = pageY
+        const bot = pageY + h
+        const isVisible = isNative
+          ? top >= 0 && bot <= window.height
+          : !(top >= window.height || bot <= 0)
+        if (!isVisible) {
+          setPlayerActive(false)
+        }
+      })
+    }, 1e3)
+    return () => {
+      unsubscribe()
+      clearInterval(interval)
+    }
+  }, [viewRef, navigation])
+
+  // calculate height for the player and the screen size
+  const height = React.useMemo(
+    () =>
+      getPlayerHeight({
+        type: params.type,
+        width: dim.width,
+        hasThumb: !!link.thumb,
+      }),
+    [params.type, dim.width, link.thumb],
+  )
+
+  const onLoad = React.useCallback(() => {
+    setIsLoading(false)
+  }, [])
+
+  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
+    // Prevent this from propagating upward on web
+    event.preventDefault()
+
+    setPlayerActive(true)
+  }, [])
+
+  // measure the layout to set sizing
+  const onLayout = React.useCallback(
+    (event: {nativeEvent: {layout: {width: any; height: any}}}) => {
+      setDim({
+        width: event.nativeEvent.layout.width,
+        height: event.nativeEvent.layout.height,
+      })
+    },
+    [],
+  )
+
+  return (
+    <View
+      ref={viewRef}
+      style={{height}}
+      collapsable={false}
+      onLayout={onLayout}>
+      {link.thumb && (!isPlayerActive || isLoading) && (
+        <Image
+          style={[
+            {
+              width: dim.width,
+              height,
+            },
+            styles.topRadius,
+          ]}
+          source={{uri: link.thumb}}
+          accessibilityIgnoresInvertColors
+        />
+      )}
+
+      <PlaceholderOverlay
+        isLoading={isLoading}
+        isPlayerActive={isPlayerActive}
+        onPress={onPlayPress}
+      />
+      <Player
+        isPlayerActive={isPlayerActive}
+        params={params}
+        height={height}
+        onLoad={onLoad}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  playerLayer: {
+    zIndex: 3,
+  },
+  webview: {
+    backgroundColor: 'transparent',
+  },
+})
diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
deleted file mode 100644
index 2f2da5662..000000000
--- a/src/view/com/util/post-embeds/YoutubeEmbed.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {AppBskyEmbedExternal} from '@atproto/api'
-import {Link} from '../Link'
-
-export const YoutubeEmbed = ({
-  link,
-  style,
-}: {
-  link: AppBskyEmbedExternal.ViewExternal
-  style?: StyleProp<ViewStyle>
-}) => {
-  const pal = usePalette('default')
-
-  const imageChild = (
-    <View style={styles.playButton}>
-      <FontAwesomeIcon icon="play" size={24} color="white" />
-    </View>
-  )
-
-  return (
-    <Link
-      asAnchor
-      style={[styles.extOuter, pal.view, pal.border, style]}
-      href={link.uri}>
-      <ExternalLinkEmbed link={link} imageChild={imageChild} />
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  extOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-  },
-  playButton: {
-    position: 'absolute',
-    alignSelf: 'center',
-    alignItems: 'center',
-    top: '44%',
-    justifyContent: 'center',
-    backgroundColor: 'black',
-    padding: 10,
-    borderRadius: 50,
-    opacity: 0.8,
-  },
-  webView: {
-    alignItems: 'center',
-    alignContent: 'center',
-    justifyContent: 'center',
-  },
-})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 2814cad87..c94ce9684 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {YoutubeEmbed} from './YoutubeEmbed'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ListEmbed} from './ListEmbed'
@@ -168,19 +166,13 @@ export function PostEmbeds({
   // =
   if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
-    const youtubeVideoId = getYoutubeVideoId(link.uri)
-
-    if (youtubeVideoId) {
-      return <YoutubeEmbed link={link} style={style} />
-    }
 
     return (
-      <Link
-        asAnchor
-        style={[styles.extOuter, pal.view, pal.border, style]}
-        href={link.uri}>
-        <ExternalLinkEmbed link={link} />
-      </Link>
+      <View style={[styles.extOuter, pal.view, pal.border, style]}>
+        <Link asAnchor href={link.uri}>
+          <ExternalLinkEmbed link={link} />
+        </Link>
+      </View>
     )
   }