about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/PostMeta.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx22
-rw-r--r--src/view/com/util/UserBanner.tsx4
-rw-r--r--src/view/com/util/UserPreviewLink.tsx4
-rw-r--r--src/view/com/util/ViewSelector.tsx91
-rw-r--r--src/view/com/util/forms/NativeDropdown.tsx4
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx20
-rw-r--r--src/view/com/util/forms/SelectableBtn.tsx23
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx127
-rw-r--r--src/view/com/util/moderation/ImageHider.tsx80
-rw-r--r--src/view/com/util/moderation/PostAlerts.tsx68
-rw-r--r--src/view/com/util/moderation/PostHider.tsx131
-rw-r--r--src/view/com/util/moderation/ProfileHeaderAlerts.tsx76
-rw-r--r--src/view/com/util/moderation/ProfileHeaderWarnings.tsx44
-rw-r--r--src/view/com/util/moderation/ScreenHider.tsx57
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx80
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx9
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx4
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx59
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx85
-rw-r--r--src/view/com/util/post-embeds/index.tsx98
21 files changed, 600 insertions, 488 deletions
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 2ce499765..bf21ff0d1 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -91,7 +91,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
 const styles = StyleSheet.create({
   metaOneLine: {
     flexDirection: 'row',
-    alignItems: 'baseline',
+    alignItems: isAndroid ? 'center' : 'baseline',
     paddingBottom: 2,
     gap: 4,
   },
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d999ffb31..0f34f75aa 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
 import Svg, {Circle, Rect, Path} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {HighPriorityImage} from 'view/com/util/images/Image'
+import {ModerationUI} from '@atproto/api'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -13,7 +14,6 @@ import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {AvatarModeration} from 'lib/labeling/types'
 import {UserPreviewLink} from './UserPreviewLink'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
 
@@ -23,7 +23,7 @@ interface BaseUserAvatarProps {
   type?: Type
   size: number
   avatar?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
 }
 
 interface UserAvatarProps extends BaseUserAvatarProps {
@@ -213,20 +213,20 @@ export function UserAvatar({
     ],
   )
 
-  const warning = useMemo(() => {
-    if (!moderation?.warn) {
+  const alert = useMemo(() => {
+    if (!moderation?.alert) {
       return null
     }
     return (
-      <View style={[styles.warningIconContainer, pal.view]}>
+      <View style={[styles.alertIconContainer, pal.view]}>
         <FontAwesomeIcon
           icon="exclamation-circle"
-          style={styles.warningIcon}
+          style={styles.alertIcon}
           size={Math.floor(size / 3)}
         />
       </View>
     )
-  }, [moderation?.warn, size, pal])
+  }, [moderation?.alert, size, pal])
 
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
@@ -259,12 +259,12 @@ export function UserAvatar({
         source={{uri: avatar}}
         blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
       />
-      {warning}
+      {alert}
     </View>
   ) : (
     <View style={{width: size, height: size}}>
       <DefaultAvatar type={type} size={size} />
-      {warning}
+      {alert}
     </View>
   )
 }
@@ -289,13 +289,13 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     backgroundColor: colors.gray5,
   },
-  warningIconContainer: {
+  alertIconContainer: {
     position: 'absolute',
     right: 0,
     bottom: 0,
     borderRadius: 100,
   },
-  warningIcon: {
+  alertIcon: {
     color: colors.red3,
   },
 })
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index b7e91b5dd..7c5c583c2 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,6 +1,7 @@
 import React, {useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
@@ -10,7 +11,6 @@ import {
   useCameraPermission,
 } from 'lib/hooks/usePermissions'
 import {usePalette} from 'lib/hooks/usePalette'
-import {AvatarModeration} from 'lib/labeling/types'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
@@ -21,7 +21,7 @@ export function UserBanner({
   onSelectNewBanner,
 }: {
   banner?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
 }) {
   const store = useStores()
diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx
index 7eedbc2d4..f43f9e80b 100644
--- a/src/view/com/util/UserPreviewLink.tsx
+++ b/src/view/com/util/UserPreviewLink.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {Pressable, StyleProp, ViewStyle} from 'react-native'
 import {useStores} from 'state/index'
 import {Link} from './Link'
-import {isDesktopWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 import {makeProfileLink} from 'lib/routes/links'
 
 interface UserPreviewLinkProps {
@@ -15,7 +15,7 @@ export function UserPreviewLink(
 ) {
   const store = useStores()
 
-  if (isDesktopWeb) {
+  if (isWeb) {
     return (
       <Link
         href={makeProfileLink(props)}
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index e2f47ba89..a25ca4d8e 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -1,5 +1,11 @@
 import React, {useEffect, useState} from 'react'
-import {Pressable, RefreshControl, StyleSheet, View} from 'react-native'
+import {
+  Pressable,
+  RefreshControl,
+  StyleSheet,
+  View,
+  ScrollView,
+} from 'react-native'
 import {FlatList} from './Views'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
@@ -140,6 +146,8 @@ export function Selector({
   items: string[]
   onSelect?: (index: number) => void
 }) {
+  const [height, setHeight] = useState(0)
+
   const pal = usePalette('default')
   const borderColor = useColorSchemeStyle(
     {borderColor: colors.black},
@@ -151,37 +159,56 @@ export function Selector({
   }
 
   return (
-    <View style={[pal.view, styles.outer]}>
-      {items.map((item, i) => {
-        const selected = i === selectedIndex
-        return (
-          <Pressable
-            testID={`selector-${i}`}
-            key={item}
-            onPress={() => onPressItem(i)}
-            accessibilityLabel={item}
-            accessibilityHint={`Selects ${item}`}
-            // TODO: Modify the component API such that lint fails
-            // at the invocation site as well
-          >
-            <View
-              style={[
-                styles.item,
-                selected && styles.itemSelected,
-                borderColor,
-              ]}>
-              <Text
-                style={
-                  selected
-                    ? [styles.labelSelected, pal.text]
-                    : [styles.label, pal.textLight]
-                }>
-                {item}
-              </Text>
-            </View>
-          </Pressable>
-        )
-      })}
+    <View
+      style={{
+        width: '100%',
+        position: 'relative',
+        overflow: 'hidden',
+        height,
+        backgroundColor: pal.colors.background,
+      }}>
+      <ScrollView
+        horizontal
+        showsHorizontalScrollIndicator={false}
+        style={{position: 'absolute'}}>
+        <View
+          style={[pal.view, styles.outer]}
+          onLayout={e => {
+            const {height} = e.nativeEvent.layout
+            setHeight(height || 60)
+          }}>
+          {items.map((item, i) => {
+            const selected = i === selectedIndex
+            return (
+              <Pressable
+                testID={`selector-${i}`}
+                key={item}
+                onPress={() => onPressItem(i)}
+                accessibilityLabel={item}
+                accessibilityHint={`Selects ${item}`}
+                // TODO: Modify the component API such that lint fails
+                // at the invocation site as well
+              >
+                <View
+                  style={[
+                    styles.item,
+                    selected && styles.itemSelected,
+                    borderColor,
+                  ]}>
+                  <Text
+                    style={
+                      selected
+                        ? [styles.labelSelected, pal.text]
+                        : [styles.label, pal.textLight]
+                    }>
+                    {item}
+                  </Text>
+                </View>
+              </Pressable>
+            )
+          })}
+        </View>
+      </ScrollView>
     </View>
   )
 }
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
index 9e6fcaa44..082285064 100644
--- a/src/view/com/util/forms/NativeDropdown.tsx
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -60,7 +60,6 @@ export const DropdownMenuTrigger = DropdownMenu.create(
                 icon="ellipsis"
                 size={20}
                 color={defaultCtrlColor}
-                style={styles.ellipsis}
               />
             )}
           </View>
@@ -252,9 +251,6 @@ const styles = StyleSheet.create({
     height: 1,
     marginVertical: 4,
   },
-  ellipsis: {
-    padding: isWeb ? 0 : 10,
-  },
   content: {
     backgroundColor: '#f0f0f0',
     borderRadius: 8,
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 27a1f20d0..969deb3ac 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,9 @@
 import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useStores} from 'state/index'
+import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
 import {
   NativeDropdown,
@@ -19,6 +22,7 @@ export function PostDropdownBtn({
   onOpenTranslate,
   onToggleThreadMute,
   onDeletePost,
+  style,
 }: {
   testID: string
   itemUri: string
@@ -31,8 +35,11 @@ export function PostDropdownBtn({
   onOpenTranslate: () => void
   onToggleThreadMute: () => void
   onDeletePost: () => void
+  style?: StyleProp<ViewStyle>
 }) {
   const store = useStores()
+  const theme = useTheme()
+  const defaultCtrlColor = theme.palette.default.postCtrl
 
   const dropdownItems: NativeDropdownItem[] = [
     {
@@ -102,9 +109,9 @@ export function PostDropdownBtn({
       label: 'Report post',
       onPress() {
         store.shell.openModal({
-          name: 'report-post',
-          postUri: itemUri,
-          postCid: itemCid,
+          name: 'report',
+          uri: itemUri,
+          cid: itemCid,
         })
       },
       testID: 'postDropdownReportBtn',
@@ -146,8 +153,11 @@ export function PostDropdownBtn({
         testID={testID}
         items={dropdownItems}
         accessibilityLabel="More post options"
-        accessibilityHint=""
-      />
+        accessibilityHint="">
+        <View style={style}>
+          <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
+        </View>
+      </NativeDropdown>
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx
index 503c49b2f..4b494264e 100644
--- a/src/view/com/util/forms/SelectableBtn.tsx
+++ b/src/view/com/util/forms/SelectableBtn.tsx
@@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isDesktopWeb} from 'platform/detection'
 
 interface SelectableBtnProps {
+  testID?: string
   selected: boolean
   label: string
   left?: boolean
@@ -15,6 +16,7 @@ interface SelectableBtnProps {
 }
 
 export function SelectableBtn({
+  testID,
   selected,
   label,
   left,
@@ -25,12 +27,15 @@ export function SelectableBtn({
 }: SelectableBtnProps) {
   const pal = usePalette('default')
   const palPrimary = usePalette('inverted')
+  const needsWidthStyles = !style || !('width' in style || 'flex' in style)
   return (
     <Pressable
+      testID={testID}
       style={[
-        styles.selectableBtn,
-        left && styles.selectableBtnLeft,
-        right && styles.selectableBtnRight,
+        styles.btn,
+        needsWidthStyles && styles.btnWidth,
+        left && styles.btnLeft,
+        right && styles.btnRight,
         pal.border,
         selected ? palPrimary.view : pal.view,
         style,
@@ -45,9 +50,7 @@ export function SelectableBtn({
 }
 
 const styles = StyleSheet.create({
-  selectableBtn: {
-    flex: isDesktopWeb ? undefined : 1,
-    width: isDesktopWeb ? 100 : undefined,
+  btn: {
     flexDirection: 'row',
     justifyContent: 'center',
     borderWidth: 1,
@@ -55,12 +58,16 @@ const styles = StyleSheet.create({
     paddingHorizontal: 10,
     paddingVertical: 10,
   },
-  selectableBtnLeft: {
+  btnWidth: {
+    flex: isDesktopWeb ? undefined : 1,
+    width: isDesktopWeb ? 100 : undefined,
+  },
+  btnLeft: {
     borderTopLeftRadius: 8,
     borderBottomLeftRadius: 8,
     borderLeftWidth: 1,
   },
-  selectableBtnRight: {
+  btnRight: {
     borderTopRightRadius: 8,
     borderBottomRightRadius: 8,
   },
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index ac5c8395d..853f7840c 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -1,36 +1,32 @@
 import React from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
+import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
-import {addStyle} from 'lib/styles'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
+import {ShieldExclamation} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 export function ContentHider({
   testID,
   moderation,
+  ignoreMute,
   style,
-  containerStyle,
+  childContainerStyle,
   children,
 }: React.PropsWithChildren<{
   testID?: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
+  ignoreMute?: boolean
   style?: StyleProp<ViewStyle>
-  containerStyle?: StyleProp<ViewStyle>
+  childContainerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const onPressShow = React.useCallback(() => {
-    setOverride(true)
-  }, [setOverride])
-  const onPressHide = React.useCallback(() => {
-    setOverride(false)
-  }, [setOverride])
 
-  if (
-    moderation.behavior === ModerationBehaviorCode.Show ||
-    moderation.behavior === ModerationBehaviorCode.Warn ||
-    moderation.behavior === ModerationBehaviorCode.WarnImages
-  ) {
+  if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -38,73 +34,72 @@ export function ContentHider({
     )
   }
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <View style={[styles.container, pal.view, pal.border, containerStyle]}>
+    <View testID={testID} style={style}>
       <Pressable
-        onPress={override ? onPressHide : onPressShow}
-        accessibilityLabel={override ? 'Hide post' : 'Show post'}
-        // TODO: The text labelling should be split up so controls have unique roles
-        accessibilityHint={
-          override
-            ? 'Re-hide post'
-            : 'Shows post hidden based on your moderation settings'
-        }
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          } else {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
         style={[
-          styles.description,
-          pal.viewLight,
-          override && styles.descriptionOpen,
+          styles.cover,
+          moderation.noOverride
+            ? {borderWidth: 1, borderColor: pal.colors.borderDark}
+            : pal.viewLight,
         ]}>
-        <Text type="md" style={pal.textLight}>
-          {moderation.reason || 'Content warning'}
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <ShieldExclamation size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
         </Text>
-        <View style={styles.showBtn}>
-          <Text type="md-medium" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </View>
-      </Pressable>
-      {override && (
-        <View style={[styles.childrenContainer, pal.border]}>
-          <View testID={testID} style={addStyle(style, styles.child)}>
-            {children}
+        {!moderation.noOverride && (
+          <View style={styles.showBtn}>
+            <Text type="xl" style={pal.link}>
+              {override ? 'Hide' : 'Show'}
+            </Text>
           </View>
-        </View>
-      )}
+        )}
+      </Pressable>
+      {override && <View style={childContainerStyle}>{children}</View>}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    marginBottom: 10,
-    borderWidth: 1,
-    borderRadius: 12,
-  },
-  description: {
+  cover: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 4,
     paddingVertical: 14,
     paddingLeft: 14,
-    paddingRight: 18,
-    borderRadius: 12,
-  },
-  descriptionOpen: {
-    borderBottomLeftRadius: 0,
-    borderBottomRightRadius: 0,
-  },
-  icon: {
-    marginRight: 10,
+    paddingRight: isDesktopWeb ? 18 : 22,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
-  childrenContainer: {
-    paddingHorizontal: 12,
-    paddingTop: 8,
-  },
-  child: {},
 })
diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx
deleted file mode 100644
index 40c9d0a21..000000000
--- a/src/view/com/util/moderation/ImageHider.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from '../text/Text'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-import {isDesktopWeb} from 'platform/detection'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
-
-export function ImageHider({
-  testID,
-  moderation,
-  style,
-  children,
-}: React.PropsWithChildren<{
-  testID?: string
-  moderation: ModerationBehavior
-  style?: StyleProp<ViewStyle>
-}>) {
-  const pal = usePalette('default')
-  const [override, setOverride] = React.useState(false)
-  const onPressToggle = React.useCallback(() => {
-    setOverride(v => !v)
-  }, [setOverride])
-
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior !== ModerationBehaviorCode.WarnImages) {
-    return (
-      <View testID={testID} style={style}>
-        {children}
-      </View>
-    )
-  }
-
-  return (
-    <View testID={testID} style={style}>
-      <View style={[styles.cover, pal.viewLight]}>
-        <Pressable
-          onPress={onPressToggle}
-          style={[styles.toggleBtn]}
-          accessibilityLabel="Show image"
-          accessibilityHint="">
-          <FontAwesomeIcon
-            icon={override ? 'eye' : ['far', 'eye-slash']}
-            size={24}
-            style={pal.text as FontAwesomeIconStyle}
-          />
-          <Text type="lg" style={pal.text}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <View style={styles.flex1} />
-          <Text type="xl-bold" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </Pressable>
-      </View>
-      {override && children}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  cover: {
-    borderRadius: 8,
-    marginTop: 4,
-  },
-  toggleBtn: {
-    flexDirection: 'row',
-    gap: 8,
-    alignItems: 'center',
-    paddingHorizontal: isDesktopWeb ? 24 : 20,
-    paddingVertical: isDesktopWeb ? 20 : 18,
-  },
-  flex1: {
-    flex: 1,
-  },
-})
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
new file mode 100644
index 000000000..8a6cbbb85
--- /dev/null
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ShieldExclamation} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function PostAlerts({
+  moderation,
+  includeMute,
+  style,
+}: {
+  moderation: ModerationUI
+  includeMute?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const shouldAlert =
+    !!moderation.cause &&
+    (moderation.alert ||
+      (includeMute && moderation.blur && moderation.cause?.type === 'muted'))
+  if (!shouldAlert) {
+    return null
+  }
+
+  const desc = describeModerationCause(moderation.cause, 'content')
+  return (
+    <Pressable
+      onPress={() => {
+        store.shell.openModal({
+          name: 'moderation-details',
+          context: 'content',
+          moderation,
+        })
+      }}
+      accessibilityRole="button"
+      accessibilityLabel="Learn more about this warning"
+      accessibilityHint=""
+      style={[styles.container, pal.viewLight, style]}>
+      <ShieldExclamation style={pal.text} size={16} />
+      <Text type="lg" style={pal.text}>
+        {desc.name}
+      </Text>
+      <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+        Learn More
+      </Text>
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    paddingVertical: 8,
+    paddingLeft: 14,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index f2b6dbddd..2a52561d4 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -1,17 +1,20 @@
 import React, {ComponentProps} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {StyleSheet, Pressable, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {ShieldExclamation} from 'lib/icons'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 interface Props extends ComponentProps<typeof Link> {
   // testID?: string
   // href?: string
   // style: StyleProp<ViewStyle>
-  moderation: ModerationBehavior
+  moderation: ModerationUI
 }
 
 export function PostHider({
@@ -22,60 +25,71 @@ export function PostHider({
   children,
   ...props
 }: Props) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const bg = override ? pal.viewLight : pal.view
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior === ModerationBehaviorCode.Warn) {
+  if (!moderation.blur) {
     return (
-      <>
-        <View style={[styles.description, bg, pal.border]}>
-          <FontAwesomeIcon
-            icon={['far', 'eye-slash']}
-            style={[styles.icon, pal.text]}
-          />
-          <Text type="md" style={pal.textLight}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <TouchableOpacity
-            style={styles.showBtn}
-            onPress={() => setOverride(v => !v)}
-            accessibilityRole="button">
-            <Text type="md" style={pal.link}>
-              {override ? 'Hide' : 'Show'} post
-            </Text>
-          </TouchableOpacity>
-        </View>
-        {override && (
-          <View style={[styles.childrenContainer, pal.border, bg]}>
-            <Link
-              testID={testID}
-              style={addStyle(style, styles.child)}
-              href={href}
-              noFeedback>
-              {children}
-            </Link>
-          </View>
-        )}
-      </>
+      <Link
+        testID={testID}
+        style={style}
+        href={href}
+        noFeedback
+        accessible={false}
+        {...props}>
+        {children}
+      </Link>
     )
   }
 
-  // NOTE: any further label enforcement should occur in ContentContainer
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <Link
-      testID={testID}
-      style={style}
-      href={href}
-      noFeedback
-      accessible={false}
-      {...props}>
-      {children}
-    </Link>
+    <>
+      <Pressable
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
+        style={[styles.description, pal.viewLight]}>
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <ShieldExclamation size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
+        </Text>
+        {!moderation.noOverride && (
+          <Text type="xl" style={[styles.showBtn, pal.link]}>
+            {override ? 'Hide' : 'Show'}
+          </Text>
+        )}
+      </Pressable>
+      {override && (
+        <View style={[styles.childrenContainer, pal.border, pal.viewLight]}>
+          <Link
+            testID={testID}
+            style={addStyle(style, styles.child)}
+            href={href}
+            noFeedback>
+            {children}
+          </Link>
+        </View>
+      )}
+    </>
   )
 }
 
@@ -83,22 +97,23 @@ const styles = StyleSheet.create({
   description: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
     paddingVertical: 14,
-    paddingHorizontal: 18,
-    borderTopWidth: 1,
-  },
-  icon: {
-    marginRight: 10,
+    paddingLeft: 18,
+    paddingRight: isDesktopWeb ? 18 : 22,
+    marginTop: 1,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
   childrenContainer: {
-    paddingHorizontal: 6,
+    paddingHorizontal: 4,
     paddingBottom: 6,
   },
   child: {
-    borderWidth: 1,
-    borderRadius: 12,
+    borderWidth: 0,
+    borderTopWidth: 0,
+    borderRadius: 8,
   },
 })
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 000000000..b7781e06d
--- /dev/null
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ProfileModeration} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ShieldExclamation} from 'lib/icons'
+import {
+  describeModerationCause,
+  getProfileModerationCauses,
+} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function ProfileHeaderAlerts({
+  moderation,
+  style,
+}: {
+  moderation: ProfileModeration
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const causes = getProfileModerationCauses(moderation)
+  if (!causes.length) {
+    return null
+  }
+
+  return (
+    <View style={styles.grid}>
+      {causes.map(cause => {
+        const desc = describeModerationCause(cause, 'account')
+        return (
+          <Pressable
+            testID="profileHeaderAlert"
+            key={desc.name}
+            onPress={() => {
+              store.shell.openModal({
+                name: 'moderation-details',
+                context: 'content',
+                moderation: {cause},
+              })
+            }}
+            accessibilityRole="button"
+            accessibilityLabel="Learn more about this warning"
+            accessibilityHint=""
+            style={[styles.container, pal.viewLight, style]}>
+            <ShieldExclamation style={pal.text} size={24} />
+            <Text type="lg" style={pal.text}>
+              {desc.name}
+            </Text>
+            <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+              Learn More
+            </Text>
+          </Pressable>
+        )
+      })}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  grid: {
+    gap: 4,
+  },
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    paddingVertical: 12,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
deleted file mode 100644
index 7a1a8e295..000000000
--- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-
-export function ProfileHeaderWarnings({
-  moderation,
-}: {
-  moderation: ModerationBehavior
-}) {
-  const palErr = usePalette('error')
-  if (moderation.behavior === ModerationBehaviorCode.Show) {
-    return null
-  }
-  return (
-    <View style={[styles.container, palErr.border, palErr.view]}>
-      <FontAwesomeIcon
-        icon="circle-exclamation"
-        style={palErr.text as FontAwesomeIconStyle}
-        size={20}
-      />
-      <Text style={palErr.text}>
-        This account has been flagged: {moderation.reason}
-      </Text>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 10,
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 10,
-    paddingVertical: 8,
-  },
-})
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
index 2e7b07e1a..b76b1101c 100644
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -1,16 +1,24 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  TouchableWithoutFeedback,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {NavigationProp} from 'lib/routes/types'
 import {Text} from '../text/Text'
 import {Button} from '../forms/Button'
 import {isDesktopWeb} from 'platform/detection'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
 
 export function ScreenHider({
   testID,
@@ -22,24 +30,17 @@ export function ScreenHider({
 }: React.PropsWithChildren<{
   testID?: string
   screenDescription: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const [override, setOverride] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
-  if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
+  if (!moderation.blur || override) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -47,6 +48,7 @@ export function ScreenHider({
     )
   }
 
+  const desc = describeModerationCause(moderation.cause, 'account')
   return (
     <View style={[styles.container, pal.view, containerStyle]}>
       <View style={styles.iconContainer}>
@@ -63,11 +65,38 @@ export function ScreenHider({
       </Text>
       <Text type="2xl" style={[styles.description, pal.textLight]}>
         This {screenDescription} has been flagged:{' '}
-        {moderation.reason || 'Content warning'}
+        <Text type="2xl-medium" style={pal.text}>
+          {desc.name}
+        </Text>
+        .{' '}
+        <TouchableWithoutFeedback
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'account',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            Learn More
+          </Text>
+        </TouchableWithoutFeedback>
       </Text>
       {!isDesktopWeb && <View style={styles.spacer} />}
       <View style={styles.btnContainer}>
-        <Button type="inverted" onPress={onPressBack} style={styles.btn}>
+        <Button
+          type="inverted"
+          onPress={() => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Home')
+            }
+          }}
+          style={styles.btn}>
           <Text type="button-lg" style={pal.textInverted}>
             Go back
           </Text>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 672e02693..c71100df0 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,11 +6,6 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-// DISABLED see #135
-// import {
-//   TriggerableAnimated,
-//   TriggerableAnimatedRef,
-// } from './anim/TriggerableAnimated'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -20,7 +15,6 @@ import {useTheme} from 'lib/ThemeContext'
 import {useStores} from 'state/index'
 import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
-import {createHitslop} from 'lib/constants'
 
 interface PostCtrlsOpts {
   itemUri: string
@@ -53,44 +47,6 @@ interface PostCtrlsOpts {
   onDeletePost: () => void
 }
 
-const HITSLOP = createHitslop(5)
-
-// DISABLED see #135
-/*
-function ctrlAnimStart(interp: Animated.Value) {
-  return Animated.sequence([
-    Animated.timing(interp, {
-      toValue: 1,
-      duration: 250,
-      useNativeDriver: true,
-    }),
-    Animated.delay(50),
-    Animated.timing(interp, {
-      toValue: 0,
-      duration: 20,
-      useNativeDriver: true,
-    }),
-  ])
-}
-
-function ctrlAnimStyle(interp: Animated.Value) {
-  return {
-    transform: [
-      {
-        scale: interp.interpolate({
-          inputRange: [0, 1.0],
-          outputRange: [1.0, 4.0],
-        }),
-      },
-    ],
-    opacity: interp.interpolate({
-      inputRange: [0, 1.0],
-      outputRange: [1.0, 0.0],
-    }),
-  }
-}
-*/
-
 export function PostCtrls(opts: PostCtrlsOpts) {
   const store = useStores()
   const theme = useTheme()
@@ -100,22 +56,11 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     }),
     [theme],
   ) as StyleProp<ViewStyle>
-  // DISABLED see #135
-  // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
-  // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
   const onRepost = useCallback(() => {
     store.shell.closeModal()
     if (!opts.isReposted) {
       Haptics.default()
       opts.onPressToggleRepost().catch(_e => undefined)
-      // DISABLED see #135
-      // repostRef.current?.trigger(
-      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
-      //   async () => {
-      //     await opts.onPressToggleRepost().catch(_e => undefined)
-      //     setRepostMod(0)
-      //   },
-      // )
     } else {
       opts.onPressToggleRepost().catch(_e => undefined)
     }
@@ -146,18 +91,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     if (!opts.isLiked) {
       Haptics.default()
       await opts.onPressToggleLike().catch(_e => undefined)
-      // DISABLED see #135
-      // likeRef.current?.trigger(
-      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
-      //   async () => {
-      //     await opts.onPressToggleLike().catch(_e => undefined)
-      //     setLikeMod(0)
-      //   },
-      // )
-      // setIsLikedPressed(false)
     } else {
       await opts.onPressToggleLike().catch(_e => undefined)
-      // setIsLikedPressed(false)
     }
   }
 
@@ -165,8 +100,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     <View style={[styles.ctrls, opts.style]}>
       <TouchableOpacity
         testID="replyBtn"
-        style={styles.ctrl}
-        hitSlop={HITSLOP}
+        style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
         onPress={opts.onPressReply}
         accessibilityRole="button"
         accessibilityLabel={`Reply (${opts.replyCount} ${
@@ -187,8 +121,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
       <TouchableOpacity
         testID="likeBtn"
-        style={styles.ctrl}
-        hitSlop={HITSLOP}
+        style={[styles.ctrl, !opts.big && styles.ctrlPad]}
         onPress={onPressToggleLikeWrapper}
         accessibilityRole="button"
         accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
@@ -232,6 +165,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           onOpenTranslate={opts.onOpenTranslate}
           onToggleThreadMute={opts.onToggleThreadMute}
           onDeletePost={opts.onDeletePost}
+          style={styles.ctrlPad}
         />
       )}
       {/* used for adding pad to the right side */}
@@ -248,8 +182,12 @@ const styles = StyleSheet.create({
   ctrl: {
     flexDirection: 'row',
     alignItems: 'center',
-    padding: 5,
-    margin: -5,
+  },
+  ctrlPad: {
+    paddingTop: 5,
+    paddingBottom: 5,
+    paddingLeft: 5,
+    paddingRight: 5,
   },
   ctrlIconLiked: {
     color: colors.like,
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 5fe62aefe..374d06515 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -6,9 +6,6 @@ import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
 import {pluralize} from 'lib/strings/helpers'
 import {useStores} from 'state/index'
-import {createHitslop} from 'lib/constants'
-
-const HITSLOP = createHitslop(5)
 
 interface Props {
   isReposted: boolean
@@ -47,9 +44,8 @@ export const RepostButton = ({
   return (
     <TouchableOpacity
       testID="repostBtn"
-      hitSlop={HITSLOP}
       onPress={onPressToggleRepostWrapper}
-      style={styles.control}
+      style={[styles.control, !big && styles.controlPad]}
       accessibilityRole="button"
       accessibilityLabel={`${
         isReposted ? 'Undo repost' : 'Repost'
@@ -83,8 +79,9 @@ const styles = StyleSheet.create({
   control: {
     flexDirection: 'row',
     alignItems: 'center',
+  },
+  controlPad: {
     padding: 5,
-    margin: -5,
   },
   reposted: {
     color: colors.green3,
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 4d2a3fcdd..eab6e2fef 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -52,6 +52,7 @@ export const RepostButton = ({
       <View
         style={[
           styles.control,
+          !big && styles.controlPad,
           (isReposted
             ? styles.reposted
             : defaultControlColor) as StyleProp<ViewStyle>,
@@ -77,6 +78,9 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     gap: 4,
   },
+  controlPad: {
+    padding: 5,
+  },
   reposted: {
     color: colors.green3,
   },
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index a4cbb3e29..81f1ca560 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -1,9 +1,11 @@
 import React from 'react'
+import {Image} from 'expo-image'
 import {Text} from '../text/Text'
-import {AutoSizedImage} from '../images/AutoSizedImage'
 import {StyleSheet, View} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {AppBskyEmbedExternal} from '@atproto/api'
+import {isDesktopWeb} from 'platform/detection'
+import {toNiceDomain} from 'lib/strings/url-helpers'
 
 export const ExternalLinkEmbed = ({
   link,
@@ -14,44 +16,71 @@ export const ExternalLinkEmbed = ({
 }) => {
   const pal = usePalette('default')
   return (
-    <>
+    <View style={styles.extContainer}>
       {link.thumb ? (
-        <AutoSizedImage uri={link.thumb} style={styles.extImage}>
+        <View style={styles.extImageContainer}>
+          <Image
+            style={styles.extImage}
+            source={{uri: link.thumb}}
+            accessibilityIgnoresInvertColors
+          />
           {imageChild}
-        </AutoSizedImage>
+        </View>
       ) : undefined}
       <View style={styles.extInner}>
-        <Text type="md-bold" numberOfLines={2} style={[pal.text]}>
-          {link.title || link.uri}
-        </Text>
         <Text
           type="sm"
           numberOfLines={1}
           style={[pal.textLight, styles.extUri]}>
-          {link.uri}
+          {toNiceDomain(link.uri)}
+        </Text>
+        <Text
+          type="lg-bold"
+          numberOfLines={isDesktopWeb ? 2 : 4}
+          style={[pal.text]}>
+          {link.title || link.uri}
         </Text>
         {link.description ? (
           <Text
-            type="sm"
-            numberOfLines={2}
+            type="md"
+            numberOfLines={isDesktopWeb ? 2 : 4}
             style={[pal.text, styles.extDescription]}>
             {link.description}
           </Text>
         ) : undefined}
       </View>
-    </>
+    </View>
   )
 }
 
 const styles = StyleSheet.create({
+  extContainer: {
+    flexDirection: isDesktopWeb ? 'row' : 'column',
+  },
   extInner: {
-    padding: 10,
+    paddingHorizontal: isDesktopWeb ? 14 : 10,
+    paddingTop: 8,
+    paddingBottom: 10,
+    flex: isDesktopWeb ? 1 : undefined,
   },
+  extImageContainer: isDesktopWeb
+    ? {
+        borderTopLeftRadius: 6,
+        borderBottomLeftRadius: 6,
+        width: 120,
+        aspectRatio: 1,
+        overflow: 'hidden',
+      }
+    : {
+        borderTopLeftRadius: 6,
+        borderTopRightRadius: 6,
+        width: '100%',
+        height: 200,
+        overflow: 'hidden',
+      },
   extImage: {
-    borderTopLeftRadius: 6,
-    borderTopRightRadius: 6,
     width: '100%',
-    maxHeight: 200,
+    height: 200,
   },
   extUri: {
     marginTop: 2,
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 4995562ac..f82b5b7df 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,6 +1,12 @@
 import React from 'react'
-import {StyleProp, StyleSheet, ViewStyle} from 'react-native'
-import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  AppBskyEmbedRecord,
+  AppBskyFeedPost,
+  AppBskyEmbedImages,
+  AppBskyEmbedRecordWithMedia,
+  ModerationUI,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
@@ -8,13 +14,68 @@ import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/models/ui/shell'
 import {PostEmbeds} from '.'
+import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
+import {InfoCircleIcon} from 'lib/icons'
+
+export function MaybeQuoteEmbed({
+  embed,
+  moderation,
+  style,
+}: {
+  embed: AppBskyEmbedRecord.View
+  moderation: ModerationUI
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  if (
+    AppBskyEmbedRecord.isViewRecord(embed.record) &&
+    AppBskyFeedPost.isRecord(embed.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.value).success
+  ) {
+    return (
+      <QuoteEmbed
+        quote={{
+          author: embed.record.author,
+          cid: embed.record.cid,
+          uri: embed.record.uri,
+          indexedAt: embed.record.indexedAt,
+          text: embed.record.value.text,
+          embeds: embed.record.embeds,
+        }}
+        moderation={moderation}
+        style={style}
+      />
+    )
+  } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Blocked
+        </Text>
+      </View>
+    )
+  } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Deleted
+        </Text>
+      </View>
+    )
+  }
+  return null
+}
 
 export function QuoteEmbed({
   quote,
+  moderation,
   style,
 }: {
   quote: ComposerOptsQuote
+  moderation?: ModerationUI
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -46,16 +107,19 @@ export function QuoteEmbed({
         postHref={itemHref}
         timestamp={quote.indexedAt}
       />
+      {moderation ? (
+        <PostAlerts moderation={moderation} style={styles.alert} />
+      ) : null}
       {!isEmpty ? (
         <Text type="post-text" style={pal.text} numberOfLines={6}>
           {quote.text}
         </Text>
       ) : null}
       {AppBskyEmbedImages.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed} />
+        <PostEmbeds embed={imagesEmbed} moderation={{}} />
       )}
       {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed.media} />
+        <PostEmbeds embed={imagesEmbed.media} moderation={{}} />
       )}
     </Link>
   )
@@ -76,4 +140,17 @@ const styles = StyleSheet.create({
     paddingLeft: 13,
     paddingRight: 8,
   },
+  errorContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 8,
+    paddingVertical: 14,
+    paddingHorizontal: 14,
+    borderWidth: 1,
+  },
+  alert: {
+    marginBottom: 6,
+  },
 })
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7ffebff54..5d0090434 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -4,17 +4,18 @@ import {
   StyleProp,
   View,
   ViewStyle,
-  Image as RNImage,
   Text,
+  InteractionManager,
 } from 'react-native'
+import {Image} from 'expo-image'
 import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
-  AppBskyFeedPost,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
+  ModerationUI,
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -24,11 +25,12 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {YoutubeEmbed} from './YoutubeEmbed'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
-import QuoteEmbed from './QuoteEmbed'
+import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {CustomFeedEmbed} from './CustomFeedEmbed'
 import {ListEmbed} from './ListEmbed'
 import {isDesktopWeb} from 'platform/detection'
+import {isCauseALabelOnUri} from 'lib/moderation'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -39,9 +41,11 @@ type Embed =
 
 export function PostEmbeds({
   embed,
+  moderation,
   style,
 }: {
   embed?: Embed
+  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -49,51 +53,37 @@ export function PostEmbeds({
 
   // quote post with media
   // =
-  if (
-    AppBskyEmbedRecordWithMedia.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
-    AppBskyFeedPost.isRecord(embed.record.record.value) &&
-    AppBskyFeedPost.validateRecord(embed.record.record.value).success
-  ) {
+  if (AppBskyEmbedRecordWithMedia.isView(embed)) {
+    const isModOnQuote =
+      AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+      isCauseALabelOnUri(moderation.cause, embed.record.record.uri)
+    const mediaModeration = isModOnQuote ? {} : moderation
+    const quoteModeration = isModOnQuote ? moderation : {}
     return (
       <View style={[styles.stackContainer, style]}>
-        <PostEmbeds embed={embed.media} />
-        <QuoteEmbed
-          quote={{
-            author: embed.record.record.author,
-            cid: embed.record.record.cid,
-            uri: embed.record.record.uri,
-            indexedAt: embed.record.record.indexedAt,
-            text: embed.record.record.value.text,
-            embeds: embed.record.record.embeds,
-          }}
-        />
+        <PostEmbeds embed={embed.media} moderation={mediaModeration} />
+        <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
       </View>
     )
   }
 
-  // quote post
-  // =
   if (AppBskyEmbedRecord.isView(embed)) {
-    if (
-      AppBskyEmbedRecord.isViewRecord(embed.record) &&
-      AppBskyFeedPost.isRecord(embed.record.value) &&
-      AppBskyFeedPost.validateRecord(embed.record.value).success
-    ) {
-      return (
-        <QuoteEmbed
-          quote={{
-            author: embed.record.author,
-            cid: embed.record.cid,
-            uri: embed.record.uri,
-            indexedAt: embed.record.indexedAt,
-            text: embed.record.value.text,
-            embeds: embed.record.embeds,
-          }}
-          style={style}
-        />
-      )
+    // custom feed embed (i.e. generator view)
+    // =
+    if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
+      return <CustomFeedEmbed record={embed.record} />
     }
+
+    // list embed (e.g. mute lists; i.e. ListView)
+    if (AppBskyGraphDefs.isListView(embed.record)) {
+      return <ListEmbed item={embed.record} />
+    }
+
+    // quote post
+    // =
+    return (
+      <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} />
+    )
   }
 
   // image embed
@@ -106,14 +96,9 @@ export function PostEmbeds({
       const openLightbox = (index: number) => {
         store.shell.openLightbox(new ImagesLightbox(items, index))
       }
-      const onPressIn = (index: number) => {
-        const firstImageToShow = items[index].uri
-        RNImage.prefetch(firstImageToShow)
-        items.forEach(item => {
-          if (firstImageToShow !== item.uri) {
-            // First image already prefetched above
-            RNImage.prefetch(item.uri)
-          }
+      const onPressIn = (_: number) => {
+        InteractionManager.runAfterInteractions(() => {
+          Image.prefetch(items.map(i => i.uri))
         })
       }
 
@@ -152,23 +137,6 @@ export function PostEmbeds({
     }
   }
 
-  // custom feed embed (i.e. generator view)
-  // =
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyFeedDefs.isGeneratorView(embed.record)
-  ) {
-    return <CustomFeedEmbed record={embed.record} />
-  }
-
-  // list embed (e.g. mute lists; i.e. ListView)
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyGraphDefs.isListView(embed.record)
-  ) {
-    return <ListEmbed item={embed.record} />
-  }
-
   // external link embed
   // =
   if (AppBskyEmbedExternal.isView(embed)) {