about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--bskylink/src/routes/index.ts4
-rw-r--r--bskylink/src/routes/redirect.ts4
-rw-r--r--bskylink/src/routes/root.ts4
-rw-r--r--src/App.native.tsx2
-rw-r--r--src/App.web.tsx4
-rw-r--r--src/alf/util/systemUI.ts2
-rw-r--r--src/components/ContextMenu/Backdrop.tsx2
-rw-r--r--src/components/FeedInterstitials.tsx18
-rw-r--r--src/components/Layout/Header/index.tsx8
-rw-r--r--src/components/Menu/context.tsx2
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx (renamed from src/view/com/util/post-embeds/ExternalGifEmbed.tsx)2
-rw-r--r--src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx (renamed from src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx)2
-rw-r--r--src/components/Post/Embed/ExternalEmbed/Gif.tsx (renamed from src/view/com/util/post-embeds/GifEmbed.tsx)0
-rw-r--r--src/components/Post/Embed/ExternalEmbed/index.tsx (renamed from src/view/com/util/post-embeds/ExternalLinkEmbed.tsx)10
-rw-r--r--src/components/Post/Embed/FeedEmbed.tsx52
-rw-r--r--src/components/Post/Embed/ImageEmbed.tsx106
-rw-r--r--src/components/Post/Embed/LazyQuoteEmbed.tsx37
-rw-r--r--src/components/Post/Embed/ListEmbed.tsx42
-rw-r--r--src/components/Post/Embed/PostPlaceholder.tsx33
-rw-r--r--src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx (renamed from src/view/com/util/post-embeds/ActiveVideoWebContext.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx)2
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts (renamed from src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx)2
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx)2
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx (renamed from src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx)6
-rw-r--r--src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx (renamed from src/view/com/util/post-embeds/VideoVolumeContext.tsx)0
-rw-r--r--src/components/Post/Embed/VideoEmbed/index.tsx (renamed from src/view/com/util/post-embeds/VideoEmbed.tsx)4
-rw-r--r--src/components/Post/Embed/VideoEmbed/index.web.tsx (renamed from src/view/com/util/post-embeds/VideoEmbed.web.tsx)10
-rw-r--r--src/components/Post/Embed/index.tsx332
-rw-r--r--src/components/Post/Embed/types.ts25
-rw-r--r--src/components/dms/ActionsWrapper.tsx2
-rw-r--r--src/components/dms/MessageItemEmbed.tsx9
-rw-r--r--src/components/hooks/dates.ts4
-rw-r--r--src/components/moderation/PostHider.tsx16
-rw-r--r--src/locale/helpers.ts2
-rw-r--r--src/screens/Login/LoginForm.tsx4
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx6
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchor.tsx4
-rw-r--r--src/screens/PostThread/components/ThreadItemPost.tsx4
-rw-r--r--src/screens/PostThread/components/ThreadItemTreePost.tsx4
-rw-r--r--src/screens/Profile/Header/Handle.tsx4
-rw-r--r--src/screens/Signup/StepInfo/Policies.tsx4
-rw-r--r--src/screens/StarterPack/StarterPackLandingScreen.tsx2
-rw-r--r--src/screens/StarterPack/Wizard/State.tsx4
-rw-r--r--src/screens/Takendown.tsx2
-rw-r--r--src/screens/VideoFeed/components/Scrubber.tsx2
-rw-r--r--src/state/messages/events/agent.ts8
-rw-r--r--src/state/queries/postgate/util.ts7
-rw-r--r--src/state/session/types.ts4
-rw-r--r--src/state/threadgate-hidden-replies.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx15
-rw-r--r--src/view/com/composer/ComposerReplyTo.tsx13
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx37
-rw-r--r--src/view/com/composer/ExternalEmbedRemoveBtn.tsx13
-rw-r--r--src/view/com/composer/GifAltText.tsx8
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx6
-rw-r--r--src/view/com/composer/photos/ImageAltTextDialog.tsx6
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx6
-rw-r--r--src/view/com/post/Post.tsx4
-rw-r--r--src/view/com/posts/PostFeedItem.tsx5
-rw-r--r--src/view/com/util/Views.web.tsx8
-rw-r--r--src/view/com/util/images/Gallery.tsx2
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx2
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx337
-rw-r--r--src/view/com/util/post-embeds/index.tsx327
-rw-r--r--src/view/com/util/post-embeds/types.ts9
75 files changed, 812 insertions, 799 deletions
diff --git a/bskylink/src/routes/index.ts b/bskylink/src/routes/index.ts
index 9fd20d276..d0122ff8b 100644
--- a/bskylink/src/routes/index.ts
+++ b/bskylink/src/routes/index.ts
@@ -1,6 +1,6 @@
-import {Express} from 'express'
+import {type Express} from 'express'
 
-import {AppContext} from '../context.js'
+import {type AppContext} from '../context.js'
 import {default as createShortLink} from './createShortLink.js'
 import {default as health} from './health.js'
 import {default as redirect} from './redirect.js'
diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts
index 468d25019..7d68e4245 100644
--- a/bskylink/src/routes/redirect.ts
+++ b/bskylink/src/routes/redirect.ts
@@ -2,9 +2,9 @@ import assert from 'node:assert'
 
 import {DAY, SECOND} from '@atproto/common'
 import escapeHTML from 'escape-html'
-import {Express} from 'express'
+import {type Express} from 'express'
 
-import {AppContext} from '../context.js'
+import {type AppContext} from '../context.js'
 import {handler} from './util.js'
 
 const INTERNAL_IP_REGEX = new RegExp(
diff --git a/bskylink/src/routes/root.ts b/bskylink/src/routes/root.ts
index 12bdf1515..8c6c4afc3 100644
--- a/bskylink/src/routes/root.ts
+++ b/bskylink/src/routes/root.ts
@@ -1,6 +1,6 @@
-import {Express} from 'express'
+import {type Express} from 'express'
 
-import {AppContext} from '../context.js'
+import {type AppContext} from '../context.js'
 import {handler} from './util.js'
 
 export default function (ctx: AppContext, app: Express) {
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 25d186dcf..81d4a870e 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -59,7 +59,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
 import {TestCtrls} from '#/view/com/testing/TestCtrls'
-import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
 import {Shell} from '#/view/shell'
 import {ThemeProvider as Alf} from '#/alf'
@@ -69,6 +68,7 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
 import {Provider as PortalProvider} from '#/components/Portal'
+import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
 import {Splash} from '#/Splash'
 import {BottomSheetProvider} from '../modules/bottom-sheet'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
diff --git a/src/App.web.tsx b/src/App.web.tsx
index fa8e24e53..b706774fd 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -48,8 +48,6 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
-import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
-import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import * as Toast from '#/view/com/util/Toast'
 import {ToastContainer} from '#/view/com/util/Toast.web'
 import {Shell} from '#/view/shell/index'
@@ -60,6 +58,8 @@ import {NuxDialogs} from '#/components/dialogs/nuxs'
 import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
 import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
 import {Provider as PortalProvider} from '#/components/Portal'
+import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
+import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
 
diff --git a/src/alf/util/systemUI.ts b/src/alf/util/systemUI.ts
index c973e10ea..9e5769c4c 100644
--- a/src/alf/util/systemUI.ts
+++ b/src/alf/util/systemUI.ts
@@ -1,7 +1,7 @@
 import * as SystemUI from 'expo-system-ui'
 
 import {isAndroid} from '#/platform/detection'
-import {Theme} from '../types'
+import {type Theme} from '../types'
 
 export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
   if (isAndroid) {
diff --git a/src/components/ContextMenu/Backdrop.tsx b/src/components/ContextMenu/Backdrop.tsx
index 027bf9849..37fcebf49 100644
--- a/src/components/ContextMenu/Backdrop.tsx
+++ b/src/components/ContextMenu/Backdrop.tsx
@@ -2,7 +2,7 @@ import {Pressable} from 'react-native'
 import Animated, {
   Extrapolation,
   interpolate,
-  SharedValue,
+  type SharedValue,
   useAnimatedStyle,
 } from 'react-native-reanimated'
 import {msg} from '@lingui/macro'
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 6ecc3f5a8..a92e7be7f 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -1,24 +1,30 @@
 import React from 'react'
 import {View} from 'react-native'
 import {ScrollView} from 'react-native-gesture-handler'
-import {AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {NavigationProp} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useGetPopularFeedsQuery} from '#/state/queries/feed'
-import {FeedDescriptor} from '#/state/queries/post-feed'
+import {type FeedDescriptor} from '#/state/queries/post-feed'
 import {useProfilesQuery} from '#/state/queries/profile'
 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
 import {useSession} from '#/state/session'
 import * as userActionHistory from '#/state/userActionHistory'
-import {SeenPost} from '#/state/userActionHistory'
+import {type SeenPost} from '#/state/userActionHistory'
 import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
-import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
+import {
+  atoms as a,
+  useBreakpoints,
+  useTheme,
+  type ViewStyleProp,
+  web,
+} from '#/alf'
 import {Button} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
 import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
@@ -27,7 +33,7 @@ import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/P
 import {InlineLinkText} from '#/components/Link'
 import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
-import * as bsky from '#/types/bsky'
+import type * as bsky from '#/types/bsky'
 import {ProgressGuideList} from './ProgressGuide/List'
 
 const MOBILE_CARD_WIDTH = 300
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index 44faa9649..d68f4bd1d 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -1,24 +1,24 @@
 import {createContext, useCallback, useContext} from 'react'
-import {GestureResponderEvent, Keyboard, View} from 'react-native'
+import {type GestureResponderEvent, Keyboard, View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
 import {HITSLOP_30} from '#/lib/constants'
-import {NavigationProp} from '#/lib/routes/types'
+import {type NavigationProp} from '#/lib/routes/types'
 import {isIOS} from '#/platform/detection'
 import {useSetDrawerOpen} from '#/state/shell'
 import {
   atoms as a,
   platform,
-  TextStyleProp,
+  type TextStyleProp,
   useBreakpoints,
   useGutters,
   useLayoutBreakpoints,
   useTheme,
   web,
 } from '#/alf'
-import {Button, ButtonIcon, ButtonProps} from '#/components/Button'
+import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
 import {
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
index d810a03de..076bc8151 100644
--- a/src/components/Menu/context.tsx
+++ b/src/components/Menu/context.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 
-import type {ContextType, ItemContextType} from '#/components/Menu/types'
+import {type ContextType, type ItemContextType} from '#/components/Menu/types'
 
 export const Context = React.createContext<ContextType | null>(null)
 
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
index 39c1d109e..8a12f0374 100644
--- a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalGif.tsx
@@ -14,7 +14,7 @@ import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
 import {Fill} from '#/components/Fill'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 
-export function ExternalGifEmbed({
+export function ExternalGif({
   link,
   params,
 }: {
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
index e78abdf17..7f6d53340 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
@@ -25,12 +25,12 @@ import {NavigationProp} from '#/lib/routes/types'
 import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
 import {isNative} from '#/platform/detection'
 import {useExternalEmbedsPrefs} from '#/state/preferences'
+import {EventStopper} from '#/view/com/util/EventStopper'
 import {atoms as a, useTheme} from '#/alf'
 import {useDialogControl} from '#/components/Dialog'
 import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
 import {Fill} from '#/components/Fill'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
-import {EventStopper} from '../EventStopper'
 
 interface ShouldStartLoadRequest {
   url: string
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
index a839294f1..a839294f1 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/components/Post/Embed/ExternalEmbed/index.tsx
index 7ca11f60d..714eaecd6 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/index.tsx
@@ -12,16 +12,16 @@ import {parseEmbedPlayerFromUrl} from '#/lib/strings/embed-player'
 import {toNiceDomain} from '#/lib/strings/url-helpers'
 import {isNative} from '#/platform/detection'
 import {useExternalEmbedsPrefs} from '#/state/preferences'
-import {ExternalGifEmbed} from '#/view/com/util/post-embeds/ExternalGifEmbed'
-import {ExternalPlayer} from '#/view/com/util/post-embeds/ExternalPlayerEmbed'
-import {GifEmbed} from '#/view/com/util/post-embeds/GifEmbed'
 import {atoms as a, useTheme} from '#/alf'
 import {Divider} from '#/components/Divider'
 import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
 import {Link} from '#/components/Link'
 import {Text} from '#/components/Typography'
+import {ExternalGif} from './ExternalGif'
+import {ExternalPlayer} from './ExternalPlayer'
+import {GifEmbed} from './Gif'
 
-export const ExternalLinkEmbed = ({
+export const ExternalEmbed = ({
   link,
   onOpen,
   style,
@@ -106,7 +106,7 @@ export const ExternalLinkEmbed = ({
           ) : undefined}
 
           {embedPlayerParams?.isGif ? (
-            <ExternalGifEmbed link={link} params={embedPlayerParams} />
+            <ExternalGif link={link} params={embedPlayerParams} />
           ) : embedPlayerParams ? (
             <ExternalPlayer link={link} params={embedPlayerParams} />
           ) : undefined}
diff --git a/src/components/Post/Embed/FeedEmbed.tsx b/src/components/Post/Embed/FeedEmbed.tsx
new file mode 100644
index 000000000..fad4cd4d8
--- /dev/null
+++ b/src/components/Post/Embed/FeedEmbed.tsx
@@ -0,0 +1,52 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {moderateFeedGenerator} from '@atproto/api'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {type EmbedType} from '#/types/bsky/post'
+import {type CommonProps} from './types'
+
+export function FeedEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'feed'>
+}) {
+  const pal = usePalette('default')
+  return (
+    <FeedSourceCard
+      feedUri={embed.view.uri}
+      style={[pal.view, pal.border, styles.customFeedOuter]}
+      showLikes
+    />
+  )
+}
+
+export function ModeratedFeedEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'feed'>
+}) {
+  const moderationOpts = useModerationOpts()
+  const moderation = React.useMemo(() => {
+    return moderationOpts
+      ? moderateFeedGenerator(embed.view, moderationOpts)
+      : undefined
+  }, [embed.view, moderationOpts])
+  return (
+    <ContentHider modui={moderation?.ui('contentList')}>
+      <FeedEmbed embed={embed} />
+    </ContentHider>
+  )
+}
+
+const styles = StyleSheet.create({
+  customFeedOuter: {
+    borderWidth: StyleSheet.hairlineWidth,
+    borderRadius: 8,
+    paddingHorizontal: 12,
+    paddingVertical: 12,
+  },
+})
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
new file mode 100644
index 000000000..030d237a0
--- /dev/null
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -0,0 +1,106 @@
+import {InteractionManager, View} from 'react-native'
+import {
+  type AnimatedRef,
+  measure,
+  type MeasuredDimensions,
+  runOnJS,
+  runOnUI,
+} from 'react-native-reanimated'
+import {Image} from 'expo-image'
+
+import {useLightboxControls} from '#/state/lightbox'
+import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
+import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage'
+import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid'
+import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
+import {type EmbedType} from '#/types/bsky/post'
+import {type CommonProps} from './types'
+
+export function ImageEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: EmbedType<'images'>
+}) {
+  const {openLightbox} = useLightboxControls()
+  const {images} = embed.view
+
+  if (images.length > 0) {
+    const items = images.map(img => ({
+      uri: img.fullsize,
+      thumbUri: img.thumb,
+      alt: img.alt,
+      dimensions: img.aspectRatio ?? null,
+    }))
+    const _openLightbox = (
+      index: number,
+      thumbRects: (MeasuredDimensions | null)[],
+      fetchedDims: (Dimensions | null)[],
+    ) => {
+      openLightbox({
+        images: items.map((item, i) => ({
+          ...item,
+          thumbRect: thumbRects[i] ?? null,
+          thumbDimensions: fetchedDims[i] ?? null,
+          type: 'image',
+        })),
+        index,
+      })
+    }
+    const onPress = (
+      index: number,
+      refs: AnimatedRef<any>[],
+      fetchedDims: (Dimensions | null)[],
+    ) => {
+      runOnUI(() => {
+        'worklet'
+        const rects: (MeasuredDimensions | null)[] = []
+        for (const r of refs) {
+          rects.push(measure(r))
+        }
+        runOnJS(_openLightbox)(index, rects, fetchedDims)
+      })()
+    }
+    const onPressIn = (_: number) => {
+      InteractionManager.runAfterInteractions(() => {
+        Image.prefetch(items.map(i => i.uri))
+      })
+    }
+
+    if (images.length === 1) {
+      const image = images[0]
+      return (
+        <View style={[a.mt_sm, rest.style]}>
+          <AutoSizedImage
+            crop={
+              rest.viewContext === PostEmbedViewContext.ThreadHighlighted
+                ? 'none'
+                : rest.viewContext ===
+                  PostEmbedViewContext.FeedEmbedRecordWithMedia
+                ? 'square'
+                : 'constrained'
+            }
+            image={image}
+            onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
+            onPressIn={() => onPressIn(0)}
+            hideBadge={
+              rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+            }
+          />
+        </View>
+      )
+    }
+
+    return (
+      <View style={[a.mt_sm, rest.style]}>
+        <ImageLayoutGrid
+          images={images}
+          onPress={onPress}
+          onPressIn={onPressIn}
+          viewContext={rest.viewContext}
+        />
+      </View>
+    )
+  }
+}
diff --git a/src/components/Post/Embed/LazyQuoteEmbed.tsx b/src/components/Post/Embed/LazyQuoteEmbed.tsx
new file mode 100644
index 000000000..fdc1c6309
--- /dev/null
+++ b/src/components/Post/Embed/LazyQuoteEmbed.tsx
@@ -0,0 +1,37 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+
+import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
+import {useResolveLinkQuery} from '#/state/queries/resolve-link'
+import {atoms as a, useTheme} from '#/alf'
+import {QuoteEmbed} from '#/components/Post/Embed'
+
+export function LazyQuoteEmbed({uri}: {uri: string}) {
+  const t = useTheme()
+  const {data} = useResolveLinkQuery(uri)
+
+  const view = useMemo(() => {
+    if (!data || data.type !== 'record' || data.kind !== 'post') return
+    return createEmbedViewRecordFromPost(data.view)
+  }, [data])
+
+  return view ? (
+    <QuoteEmbed
+      embed={{
+        type: 'post',
+        view,
+      }}
+    />
+  ) : (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_md,
+        t.atoms.bg_contrast_25,
+        {
+          height: 68,
+        },
+      ]}
+    />
+  )
+}
diff --git a/src/components/Post/Embed/ListEmbed.tsx b/src/components/Post/Embed/ListEmbed.tsx
new file mode 100644
index 000000000..dc79a7579
--- /dev/null
+++ b/src/components/Post/Embed/ListEmbed.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+import {View} from 'react-native'
+import {moderateUserList} from '@atproto/api'
+
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {EmbedType} from '#/types/bsky/post'
+import {CommonProps} from './types'
+
+export function ListEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'list'>
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[a.border, t.atoms.border_contrast_medium, a.p_md, a.rounded_sm]}>
+      <ListCard.Default view={embed.view} />
+    </View>
+  )
+}
+
+export function ModeratedListEmbed({
+  embed,
+}: CommonProps & {
+  embed: EmbedType<'list'>
+}) {
+  const moderationOpts = useModerationOpts()
+  const moderation = React.useMemo(() => {
+    return moderationOpts
+      ? moderateUserList(embed.view, moderationOpts)
+      : undefined
+  }, [embed.view, moderationOpts])
+  return (
+    <ContentHider modui={moderation?.ui('contentList')}>
+      <ListEmbed embed={embed} />
+    </ContentHider>
+  )
+}
diff --git a/src/components/Post/Embed/PostPlaceholder.tsx b/src/components/Post/Embed/PostPlaceholder.tsx
new file mode 100644
index 000000000..840234026
--- /dev/null
+++ b/src/components/Post/Embed/PostPlaceholder.tsx
@@ -0,0 +1,33 @@
+import {StyleSheet, View} from 'react-native'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {InfoCircleIcon} from '#/lib/icons'
+import {Text} from '#/view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+export function PostPlaceholder({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  const pal = usePalette('default')
+  return (
+    <View
+      style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
+      <InfoCircleIcon size={18} style={pal.text} />
+      <Text type="lg" style={pal.text}>
+        {children}
+      </Text>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  errorContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 8,
+    paddingVertical: 14,
+    paddingHorizontal: 14,
+    borderWidth: StyleSheet.hairlineWidth,
+  },
+})
diff --git a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx
index a038403b2..a038403b2 100644
--- a/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
+++ b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx
index 95401309f..95401309f 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 8b44f5448..88879d45a 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -7,7 +7,6 @@ import {useLingui} from '@lingui/react'
 
 import {HITSLOP_30} from '#/lib/constants'
 import {useAutoplayDisabled} from '#/state/preferences'
-import {useVideoMuteState} from '#/view/com/util/post-embeds/VideoVolumeContext'
 import {atoms as a, useTheme} from '#/alf'
 import {useIsWithinMessage} from '#/components/dms/MessageContext'
 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -15,6 +14,7 @@ import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Paus
 import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
 import {TimeIndicator} from './TimeIndicator'
 
 export const VideoEmbedInnerNative = React.forwardRef(
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
index 2760c7faf..2760c7faf 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
index 8664aae14..8664aae14 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index ce3a7b2c9..ce3a7b2c9 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx
index 1b46163cc..1b46163cc 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts
index 122e10aef..122e10aef 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/bandwidth-estimate.ts
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx
index 651046445..1b69a3e25 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/ControlButton.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
 import {SvgProps} from 'react-native-svg'
 
+import {PressableWithHover} from '#/view/com/util/PressableWithHover'
 import {atoms as a, useTheme, web} from '#/alf'
-import {PressableWithHover} from '../../../PressableWithHover'
 
 export function ControlButton({
   active,
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
index 96960bad4..96960bad4 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx
index e2e24ed36..e2e24ed36 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.native.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
index 6d14deafc..6d14deafc 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx
index 90ffb9e6b..e0b688075 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VolumeControl.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx
@@ -8,7 +8,7 @@ import {isSafari, isTouchDevice} from '#/lib/browser'
 import {atoms as a} from '#/alf'
 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
-import {useVideoVolumeState} from '../../VideoVolumeContext'
+import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
 import {ControlButton} from './ControlButton'
 
 export function VolumeControl({
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx
index 108814ea2..320f61a5f 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx
@@ -1,9 +1,9 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {type RefObject, useCallback, useEffect, useRef, useState} from 'react'
 
 import {isSafari} from '#/lib/browser'
-import {useVideoVolumeState} from '../../VideoVolumeContext'
+import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
 
-export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
+export function useVideoElement(ref: RefObject<HTMLVideoElement>) {
   const [playing, setPlaying] = useState(false)
   const [muted, setMuted] = useState(true)
   const [currentTime, setCurrentTime] = useState(0)
diff --git a/src/view/com/util/post-embeds/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx
index 6343081da..6343081da 100644
--- a/src/view/com/util/post-embeds/VideoVolumeContext.tsx
+++ b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx
index b45027089..fe29ecad6 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/components/Post/Embed/VideoEmbed/index.tsx
@@ -5,13 +5,13 @@ import {AppBskyEmbedVideo} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
-import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {useThrottledValue} from '#/components/hooks/useThrottledValue'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
-import {ErrorBoundary} from '../ErrorBoundary'
+import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative'
 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
 
 interface Props {
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx
index b0ded6754..53adc3b6a 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx
@@ -5,16 +5,16 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {isFirefox} from '#/lib/browser'
+import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage'
+import {atoms as a} from '#/alf'
+import {useIsWithinMessage} from '#/components/dms/MessageContext'
+import {useFullscreen} from '#/components/hooks/useFullscreen'
 import {
   HLSUnsupportedError,
   VideoEmbedInnerWeb,
   VideoNotFoundError,
-} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
-import {atoms as a} from '#/alf'
-import {useIsWithinMessage} from '#/components/dms/MessageContext'
-import {useFullscreen} from '#/components/hooks/useFullscreen'
-import {ErrorBoundary} from '../ErrorBoundary'
+} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb'
 import {useActiveVideoWeb} from './ActiveVideoWebContext'
 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
 
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
new file mode 100644
index 000000000..ace85dc98
--- /dev/null
+++ b/src/components/Post/Embed/index.tsx
@@ -0,0 +1,332 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  type $Typed,
+  type AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AtUri,
+  moderatePost,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {usePalette} from '#/lib/hooks/usePalette'
+import {makeProfileLink} from '#/lib/routes/links'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {unstableCacheProfileView} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {Link} from '#/view/com/util/Link'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {atoms as a, useTheme} from '#/alf'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {RichText} from '#/components/RichText'
+import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {SubtleWebHover} from '#/components/SubtleWebHover'
+import * as bsky from '#/types/bsky'
+import {
+  type Embed as TEmbed,
+  type EmbedType,
+  parseEmbed,
+} from '#/types/bsky/post'
+import {ExternalEmbed} from './ExternalEmbed'
+import {ModeratedFeedEmbed} from './FeedEmbed'
+import {ImageEmbed} from './ImageEmbed'
+import {ModeratedListEmbed} from './ListEmbed'
+import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
+import {
+  type CommonProps,
+  type EmbedProps,
+  PostEmbedViewContext,
+  QuoteEmbedViewContext,
+} from './types'
+import {VideoEmbed} from './VideoEmbed'
+
+export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
+
+export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
+  const embed = parseEmbed(rawEmbed)
+
+  switch (embed.type) {
+    case 'images':
+    case 'link':
+    case 'video': {
+      return <MediaEmbed embed={embed} {...rest} />
+    }
+    case 'feed':
+    case 'list':
+    case 'starter_pack':
+    case 'labeler':
+    case 'post':
+    case 'post_not_found':
+    case 'post_blocked':
+    case 'post_detached': {
+      return <RecordEmbed embed={embed} {...rest} />
+    }
+    case 'post_with_media': {
+      return (
+        <View style={rest.style}>
+          <MediaEmbed embed={embed.media} {...rest} />
+          <RecordEmbed embed={embed.view} {...rest} />
+        </View>
+      )
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+function MediaEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: TEmbed
+}) {
+  switch (embed.type) {
+    case 'images': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <ImageEmbed embed={embed} {...rest} />
+        </ContentHider>
+      )
+    }
+    case 'link': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <ExternalEmbed
+            link={embed.view.external}
+            onOpen={rest.onOpen}
+            style={[a.mt_sm, rest.style]}
+          />
+        </ContentHider>
+      )
+    }
+    case 'video': {
+      return (
+        <ContentHider modui={rest.moderation?.ui('contentMedia')}>
+          <VideoEmbed embed={embed.view} />
+        </ContentHider>
+      )
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+function RecordEmbed({
+  embed,
+  ...rest
+}: CommonProps & {
+  embed: TEmbed
+}) {
+  switch (embed.type) {
+    case 'feed': {
+      return (
+        <View style={a.mt_sm}>
+          <ModeratedFeedEmbed embed={embed} {...rest} />
+        </View>
+      )
+    }
+    case 'list': {
+      return (
+        <View style={a.mt_sm}>
+          <ModeratedListEmbed embed={embed} />
+        </View>
+      )
+    }
+    case 'starter_pack': {
+      return (
+        <View style={a.mt_sm}>
+          <StarterPackCard starterPack={embed.view} />
+        </View>
+      )
+    }
+    case 'labeler': {
+      // not implemented
+      return null
+    }
+    case 'post': {
+      if (rest.isWithinQuote && !rest.allowNestedQuotes) {
+        return null
+      }
+
+      return (
+        <QuoteEmbed
+          {...rest}
+          embed={embed}
+          viewContext={
+            rest.viewContext === PostEmbedViewContext.Feed
+              ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
+              : undefined
+          }
+          isWithinQuote={rest.isWithinQuote}
+          allowNestedQuotes={rest.allowNestedQuotes}
+        />
+      )
+    }
+    case 'post_not_found': {
+      return (
+        <PostPlaceholderText>
+          <Trans>Deleted</Trans>
+        </PostPlaceholderText>
+      )
+    }
+    case 'post_blocked': {
+      return (
+        <PostPlaceholderText>
+          <Trans>Blocked</Trans>
+        </PostPlaceholderText>
+      )
+    }
+    case 'post_detached': {
+      return <PostDetachedEmbed embed={embed} />
+    }
+    default: {
+      return null
+    }
+  }
+}
+
+export function PostDetachedEmbed({
+  embed,
+}: {
+  embed: EmbedType<'post_detached'>
+}) {
+  const {currentAccount} = useSession()
+  const isViewerOwner = currentAccount?.did
+    ? embed.view.uri.includes(currentAccount.did)
+    : false
+
+  return (
+    <PostPlaceholderText>
+      {isViewerOwner ? (
+        <Trans>Removed by you</Trans>
+      ) : (
+        <Trans>Removed by author</Trans>
+      )}
+    </PostPlaceholderText>
+  )
+}
+
+/*
+ * Nests parent `Embed` component and therefore must live in this file to avoid
+ * circular imports.
+ */
+export function QuoteEmbed({
+  embed,
+  onOpen,
+  style,
+  isWithinQuote: parentIsWithinQuote,
+  allowNestedQuotes: parentAllowNestedQuotes,
+}: Omit<CommonProps, 'viewContext'> & {
+  embed: EmbedType<'post'>
+  viewContext?: QuoteEmbedViewContext
+}) {
+  const moderationOpts = useModerationOpts()
+  const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
+    () => ({
+      ...embed.view,
+      $type: 'app.bsky.feed.defs#postView',
+      record: embed.view.value,
+      embed: embed.view.embeds?.[0],
+    }),
+    [embed],
+  )
+  const moderation = React.useMemo(() => {
+    return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
+  }, [quote, moderationOpts])
+
+  const t = useTheme()
+  const queryClient = useQueryClient()
+  const pal = usePalette('default')
+  const itemUrip = new AtUri(quote.uri)
+  const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
+  const itemTitle = `Post by ${quote.author.handle}`
+
+  const richText = React.useMemo(() => {
+    if (
+      !bsky.dangerousIsType<AppBskyFeedPost.Record>(
+        quote.record,
+        AppBskyFeedPost.isRecord,
+      )
+    )
+      return undefined
+    const {text, facets} = quote.record
+    return text.trim()
+      ? new RichTextAPI({text: text, facets: facets})
+      : undefined
+  }, [quote.record])
+
+  const onBeforePress = React.useCallback(() => {
+    unstableCacheProfileView(queryClient, quote.author)
+    onOpen?.()
+  }, [queryClient, quote.author, onOpen])
+
+  const [hover, setHover] = React.useState(false)
+  return (
+    <View
+      onPointerEnter={() => {
+        setHover(true)
+      }}
+      onPointerLeave={() => {
+        setHover(false)
+      }}>
+      <ContentHider
+        modui={moderation?.ui('contentList')}
+        style={[
+          a.rounded_md,
+          a.p_md,
+          a.mt_sm,
+          a.border,
+          t.atoms.border_contrast_low,
+          style,
+        ]}
+        childContainerStyle={[a.pt_sm]}>
+        <SubtleWebHover hover={hover} />
+        <Link
+          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+          href={itemHref}
+          title={itemTitle}
+          onBeforePress={onBeforePress}>
+          <View pointerEvents="none">
+            <PostMeta
+              author={quote.author}
+              moderation={moderation}
+              showAvatar
+              postHref={itemHref}
+              timestamp={quote.indexedAt}
+            />
+          </View>
+          {moderation ? (
+            <PostAlerts
+              modui={moderation.ui('contentView')}
+              style={[a.py_xs]}
+            />
+          ) : null}
+          {richText ? (
+            <RichText
+              value={richText}
+              style={a.text_md}
+              numberOfLines={20}
+              disableLinks
+            />
+          ) : null}
+          {quote.embed && (
+            <Embed
+              embed={quote.embed}
+              moderation={moderation}
+              isWithinQuote={parentIsWithinQuote ?? true}
+              // already within quote? override nested
+              allowNestedQuotes={
+                parentIsWithinQuote ? false : parentAllowNestedQuotes
+              }
+            />
+          )}
+        </Link>
+      </ContentHider>
+    </View>
+  )
+}
diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts
new file mode 100644
index 000000000..b719d00b4
--- /dev/null
+++ b/src/components/Post/Embed/types.ts
@@ -0,0 +1,25 @@
+import {type StyleProp, type ViewStyle} from 'react-native'
+import {type AppBskyFeedDefs, type ModerationDecision} from '@atproto/api'
+
+export enum PostEmbedViewContext {
+  ThreadHighlighted = 'ThreadHighlighted',
+  Feed = 'Feed',
+  FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+  FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}
+
+export type CommonProps = {
+  moderation?: ModerationDecision
+  onOpen?: () => void
+  style?: StyleProp<ViewStyle>
+  viewContext?: PostEmbedViewContext
+  isWithinQuote?: boolean
+  allowNestedQuotes?: boolean
+}
+
+export type EmbedProps = CommonProps & {
+  embed?: AppBskyFeedDefs.PostView['embed']
+}
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index 120a5f8ad..eb9f0a09a 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -1,5 +1,5 @@
 import {View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index f1c6189d0..6390300c1 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -1,15 +1,16 @@
 import React from 'react'
 import {useWindowDimensions, View} from 'react-native'
-import {AppBskyEmbedRecord} from '@atproto/api'
+import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api'
 
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {atoms as a, native, tokens, useTheme, web} from '#/alf'
+import {PostEmbedViewContext} from '#/components/Post/Embed'
+import {Embed} from '#/components/Post/Embed'
 import {MessageContextProvider} from './MessageContext'
 
 let MessageItemEmbed = ({
   embed,
 }: {
-  embed: AppBskyEmbedRecord.View
+  embed: $Typed<AppBskyEmbedRecord.View>
 }): React.ReactNode => {
   const t = useTheme()
   const screen = useWindowDimensions()
@@ -32,7 +33,7 @@ let MessageItemEmbed = ({
           }),
         ]}>
         <View style={{marginTop: tokens.space.sm * -1}}>
-          <PostEmbeds
+          <Embed
             embed={embed}
             allowNestedQuotes
             viewContext={PostEmbedViewContext.Feed}
diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts
index b33a866d1..898e245ec 100644
--- a/src/components/hooks/dates.ts
+++ b/src/components/hooks/dates.ts
@@ -8,7 +8,7 @@
  */
 
 import React from 'react'
-import {formatDistance, Locale} from 'date-fns'
+import {formatDistance, type Locale} from 'date-fns'
 import {
   ca,
   cy,
@@ -47,7 +47,7 @@ import {
   zhTW,
 } from 'date-fns/locale'
 
-import {AppLanguage} from '#/locale/languages'
+import {type AppLanguage} from '#/locale/languages'
 import {useLanguagePrefs} from '#/state/preferences'
 
 /**
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 03a58ab0b..057c77023 100644
--- a/src/components/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -1,6 +1,16 @@
-import React, {ComponentProps} from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
+import React, {type ComponentProps} from 'react'
+import {
+  Pressable,
+  type StyleProp,
+  StyleSheet,
+  View,
+  type ViewStyle,
+} from 'react-native'
+import {
+  type AppBskyActorDefs,
+  type ModerationCause,
+  type ModerationUI,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts
index 8d650a234..380f996b9 100644
--- a/src/locale/helpers.ts
+++ b/src/locale/helpers.ts
@@ -1,4 +1,4 @@
-import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
+import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
 import * as bcp47Match from 'bcp-47-match'
 import lande from 'lande'
 
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
index 6c3d7633a..b6a528e42 100644
--- a/src/screens/Login/LoginForm.tsx
+++ b/src/screens/Login/LoginForm.tsx
@@ -3,12 +3,12 @@ import {
   ActivityIndicator,
   Keyboard,
   LayoutAnimation,
-  TextInput,
+  type TextInput,
   View,
 } from 'react-native'
 import {
   ComAtprotoServerCreateSession,
-  ComAtprotoServerDescribeServer,
+  type ComAtprotoServerDescribeServer,
 } from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 0e738f145..3d2c551e9 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {View} from 'react-native'
 import {Image as ExpoImage} from 'expo-image'
 import {
-  ImagePickerOptions,
+  type ImagePickerOptions,
   launchImageLibraryAsync,
   MediaTypeOptions,
 } from 'expo-image-picker'
@@ -27,7 +27,7 @@ import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreato
 import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems'
 import {
   PlaceholderCanvas,
-  PlaceholderCanvasRef,
+  type PlaceholderCanvasRef,
 } from '#/screens/Onboarding/StepProfile/PlaceholderCanvas'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -38,7 +38,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components
 import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo'
 import {StreamingLive_Stroke2_Corner0_Rounded as StreamingLive} from '#/components/icons/StreamingLive'
 import {Text} from '#/components/Typography'
-import {AvatarColor, avatarColors, Emoji, emojiItems} from './types'
+import {type AvatarColor, avatarColors, type Emoji, emojiItems} from './types'
 
 export interface Avatar {
   image?: {
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index 0aacd4e77..d1e3518cc 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -36,7 +36,6 @@ import {type PostSource} from '#/state/unstable-post-source'
 import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
 import {Link} from '#/view/com/util/Link'
 import {formatCount} from '#/view/com/util/numeric/format'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {
   LINEAR_AVI_WIDTH,
@@ -53,6 +52,7 @@ import {ContentHider} from '#/components/moderation/ContentHider'
 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {type AppModerationCause} from '#/components/Pills'
+import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
@@ -388,7 +388,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
             ) : undefined}
             {post.embed && (
               <View style={[a.py_xs]}>
-                <PostEmbeds
+                <Embed
                   embed={post.embed}
                   moderation={moderation}
                   viewContext={PostEmbedViewContext.ThreadHighlighted}
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
index 1f63b10cd..9393a6d1b 100644
--- a/src/screens/PostThread/components/ThreadItemPost.tsx
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -25,7 +25,6 @@ import {useSession} from '#/state/session'
 import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {TextLink} from '#/view/com/util/Link'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {
@@ -40,6 +39,7 @@ import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {PostHider} from '#/components/moderation/PostHider'
 import {type AppModerationCause} from '#/components/Pills'
+import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
 import {RichText} from '#/components/RichText'
 import * as Skele from '#/components/Skeleton'
@@ -323,7 +323,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
               ) : undefined}
               {post.embed && (
                 <View style={[a.pb_xs]}>
-                  <PostEmbeds
+                  <Embed
                     embed={post.embed}
                     moderation={moderation}
                     viewContext={PostEmbedViewContext.Feed}
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
index d86d2ef6f..ac659a6e0 100644
--- a/src/screens/PostThread/components/ThreadItemTreePost.tsx
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -24,7 +24,6 @@ import {useSession} from '#/state/session'
 import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {TextLink} from '#/view/com/util/Link'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {
   OUTER_SPACE,
@@ -39,6 +38,7 @@ import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {PostHider} from '#/components/moderation/PostHider'
 import {type AppModerationCause} from '#/components/Pills'
+import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
 import {RichText} from '#/components/RichText'
 import * as Skele from '#/components/Skeleton'
@@ -369,7 +369,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
                   ) : undefined}
                   {post.embed && (
                     <View style={[a.pb_xs]}>
-                      <PostEmbeds
+                      <Embed
                         embed={post.embed}
                         moderation={moderation}
                         viewContext={PostEmbedViewContext.Feed}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index a8bf65692..cfbf430c4 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -1,11 +1,11 @@
 import {View} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {isInvalidHandle, sanitizeHandle} from '#/lib/strings/handles'
 import {isIOS} from '#/platform/detection'
-import {Shadow} from '#/state/cache/types'
+import {type Shadow} from '#/state/cache/types'
 import {atoms as a, useTheme, web} from '#/alf'
 import {NewskieDialog} from '#/components/NewskieDialog'
 import {Text} from '#/components/Typography'
diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx
index 81533c58e..17980172d 100644
--- a/src/screens/Signup/StepInfo/Policies.tsx
+++ b/src/screens/Signup/StepInfo/Policies.tsx
@@ -1,6 +1,6 @@
-import {ReactElement} from 'react'
+import {type ReactElement} from 'react'
 import {View} from 'react-native'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {type ComAtprotoServerDescribeServer} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx
index b522bc906..39ae57855 100644
--- a/src/screens/StarterPack/StarterPackLandingScreen.tsx
+++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx
@@ -5,7 +5,7 @@ import {
   AppBskyGraphDefs,
   AppBskyGraphStarterpack,
   AtUri,
-  ModerationOpts,
+  type ModerationOpts,
 } from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg, Trans} from '@lingui/macro'
diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx
index 1ecd038a4..07d744c06 100644
--- a/src/screens/StarterPack/Wizard/State.tsx
+++ b/src/screens/StarterPack/Wizard/State.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
-import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
-import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {type AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {type GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
 import {msg, plural} from '@lingui/macro'
 
 import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
diff --git a/src/screens/Takendown.tsx b/src/screens/Takendown.tsx
index ef3e93658..d01903eb5 100644
--- a/src/screens/Takendown.tsx
+++ b/src/screens/Takendown.tsx
@@ -3,7 +3,7 @@ import {Modal, View} from 'react-native'
 import {SystemBars} from 'react-native-edge-to-edge'
 import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api'
+import {type ComAtprotoAdminDefs, ComAtprotoModerationDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useMutation} from '@tanstack/react-query'
diff --git a/src/screens/VideoFeed/components/Scrubber.tsx b/src/screens/VideoFeed/components/Scrubber.tsx
index ef3190526..29cc4b278 100644
--- a/src/screens/VideoFeed/components/Scrubber.tsx
+++ b/src/screens/VideoFeed/components/Scrubber.tsx
@@ -22,9 +22,9 @@ import {
 import {useEventListener} from 'expo'
 import {VideoPlayer} from 'expo-video'
 
-import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
 import {tokens} from '#/alf'
 import {atoms as a} from '#/alf'
+import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
 import {Text} from '#/components/Typography'
 
 // magic number that is roughly the min height of the write reply button
diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts
index 1a6cfb3f2..589c5b6d3 100644
--- a/src/state/messages/events/agent.ts
+++ b/src/state/messages/events/agent.ts
@@ -1,4 +1,4 @@
-import {BskyAgent, ChatBskyConvoGetLog} from '@atproto/api'
+import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api'
 import EventEmitter from 'eventemitter3'
 import {nanoid} from 'nanoid/non-secure'
 
@@ -9,11 +9,11 @@ import {
   DEFAULT_POLL_INTERVAL,
 } from '#/state/messages/events/const'
 import {
-  MessagesEventBusDispatch,
+  type MessagesEventBusDispatch,
   MessagesEventBusDispatchEvent,
   MessagesEventBusErrorCode,
-  MessagesEventBusEvent,
-  MessagesEventBusParams,
+  type MessagesEventBusEvent,
+  type MessagesEventBusParams,
   MessagesEventBusStatus,
 } from '#/state/messages/events/types'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts
index c1955cc74..0952a1ad0 100644
--- a/src/state/queries/postgate/util.ts
+++ b/src/state/queries/postgate/util.ts
@@ -1,9 +1,9 @@
 import {
-  $Typed,
+  type $Typed,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
-  AppBskyFeedDefs,
-  AppBskyFeedPostgate,
+  type AppBskyFeedDefs,
+  type AppBskyFeedPostgate,
   AtUri,
 } from '@atproto/api'
 
@@ -113,6 +113,7 @@ export function createEmbedViewRecordFromPost(
     likeCount: post.likeCount,
     quoteCount: post.quoteCount,
     indexedAt: post.indexedAt,
+    embeds: post.embed ? [post.embed] : [],
   }
 }
 
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
index 9aadf9d05..aa8b9a99e 100644
--- a/src/state/session/types.ts
+++ b/src/state/session/types.ts
@@ -1,5 +1,5 @@
-import {LogEvents} from '#/lib/statsig/statsig'
-import {PersistedAccount} from '#/state/persisted'
+import {type LogEvents} from '#/lib/statsig/statsig'
+import {type PersistedAccount} from '#/state/persisted'
 
 export type SessionAccount = PersistedAccount
 
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
index 9d116c7f9..8a3ee0f24 100644
--- a/src/state/threadgate-hidden-replies.tsx
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {AppBskyFeedThreadgate} from '@atproto/api'
+import {type AppBskyFeedThreadgate} from '@atproto/api'
 
 type StateContext = {
   uris: Set<string>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 17d0f94f7..de060c6c2 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -72,7 +72,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {mimeToExt} from '#/lib/media/video/util'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
-import {colors, s} from '#/lib/styles'
+import {colors} from '#/lib/styles'
 import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
@@ -97,6 +97,7 @@ import {
   ExternalEmbedGif,
   ExternalEmbedLink,
 } from '#/view/com/composer/ExternalEmbed'
+import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
 import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
 import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
 import {Gallery} from '#/view/com/composer/photos/Gallery'
@@ -116,7 +117,6 @@ import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
-import {LazyQuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
 import {Text} from '#/view/com/util/text/Text'
 import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -125,6 +125,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
 import * as Prompt from '#/components/Prompt'
 import {Text as NewText} from '#/components/Typography'
 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
@@ -1149,13 +1150,17 @@ function ComposerEmbeds({
         )}
       </LayoutAnimationConfig>
       {embed.quote?.uri ? (
-        <View style={!video ? [a.mt_md] : []}>
-          <View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
+        <View
+          style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], isWeb && [a.pb_md]]}>
+          <View style={[a.relative]}>
             <View style={{pointerEvents: 'none'}}>
               <LazyQuoteEmbed uri={embed.quote.uri} />
             </View>
             {canRemoveQuote && (
-              <QuoteX onRemove={() => dispatch({type: 'embed_remove_quote'})} />
+              <ExternalEmbedRemoveBtn
+                onRemove={() => dispatch({type: 'embed_remove_quote'})}
+                style={{top: 16}}
+              />
             )}
           </View>
         </View>
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 0ced14359..acab84f65 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -13,12 +13,13 @@ import {useLingui} from '@lingui/react'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {type ComposerOptsPostRef} from '#/state/shell/composer'
-import {MaybeQuoteEmbed} from '#/view/com/util/post-embeds/QuoteEmbed'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme, web} from '#/alf'
+import {QuoteEmbed} from '#/components/Post/Embed'
 import {Text} from '#/components/Typography'
 import {useSimpleVerificationState} from '#/components/verification'
 import {VerificationCheck} from '#/components/verification/VerificationCheck'
+import {parseEmbed} from '#/types/bsky/post'
 
 export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
   const t = useTheme()
@@ -51,6 +52,12 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
     }
     return null
   }, [embed])
+  const parsedQuoteEmbed = quoteEmbed
+    ? parseEmbed({
+        $type: 'app.bsky.embed.record#view',
+        ...quoteEmbed,
+      })
+    : null
 
   const images = useMemo(() => {
     if (AppBskyEmbedImages.isView(embed)) {
@@ -124,7 +131,9 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
             <ComposerReplyToImages images={images} showFull={showFull} />
           )}
         </View>
-        {showFull && quoteEmbed && <MaybeQuoteEmbed embed={quoteEmbed} />}
+        {showFull && parsedQuoteEmbed && parsedQuoteEmbed.type === 'post' && (
+          <QuoteEmbed embed={parsedQuoteEmbed} />
+        )}
       </View>
     </Pressable>
   )
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index d819b28b7..e4bdabac3 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -1,19 +1,20 @@
 import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
 
 import {cleanError} from '#/lib/strings/errors'
 import {
   useResolveGifQuery,
   useResolveLinkQuery,
 } from '#/state/queries/resolve-link'
-import {Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/state/queries/tenor'
 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
-import {ExternalLinkEmbed} from '#/view/com/util/post-embeds/ExternalLinkEmbed'
 import {atoms as a, useTheme} from '#/alf'
 import {Loader} from '#/components/Loader'
+import {ExternalEmbed} from '#/components/Post/Embed/ExternalEmbed'
+import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
+import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
 import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
 import {Text} from '#/components/Typography'
-import {MaybeFeedCard, MaybeListCard} from '../util/post-embeds'
 
 export const ExternalEmbedGif = ({
   onRemove,
@@ -44,7 +45,7 @@ export const ExternalEmbedGif = ({
     <View style={[a.overflow_hidden, t.atoms.border_contrast_medium]}>
       {linkInfo ? (
         <View style={{pointerEvents: 'auto'}}>
-          <ExternalLinkEmbed link={linkInfo} hideAlt />
+          <ExternalEmbed link={linkInfo} hideAlt />
         </View>
       ) : error ? (
         <Container style={[a.align_start, a.p_md, a.gap_xs]}>
@@ -80,7 +81,7 @@ export const ExternalEmbedLink = ({
     if (data) {
       if (data.type === 'external') {
         return (
-          <ExternalLinkEmbed
+          <ExternalEmbed
             link={{
               title: data.title || uri,
               uri,
@@ -91,9 +92,29 @@ export const ExternalEmbedLink = ({
           />
         )
       } else if (data.kind === 'feed') {
-        return <MaybeFeedCard view={data.view} />
+        return (
+          <ModeratedFeedEmbed
+            embed={{
+              type: 'feed',
+              view: {
+                $type: 'app.bsky.feed.defs#generatorView',
+                ...data.view,
+              },
+            }}
+          />
+        )
       } else if (data.kind === 'list') {
-        return <MaybeListCard view={data.view} />
+        return (
+          <ModeratedListEmbed
+            embed={{
+              type: 'list',
+              view: {
+                $type: 'app.bsky.graph.defs#listView',
+                ...data.view,
+              },
+            }}
+          />
+        )
       } else if (data.kind === 'starter-pack') {
         return <StarterPackEmbed starterPack={data.view} />
       }
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
index 92102f847..1e363d018 100644
--- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
+++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
@@ -2,22 +2,27 @@ import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
 import {Button, ButtonIcon} from '#/components/Button'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 
-export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
+export function ExternalEmbedRemoveBtn({
+  onRemove,
+  style,
+}: {onRemove: () => void} & ViewStyleProp) {
+  const t = useTheme()
   const {_} = useLingui()
 
   return (
-    <View style={[a.absolute, {top: 8, right: 8}, a.z_50]}>
+    <View style={[a.absolute, {top: 8, right: 8}, a.z_50, style]}>
       <Button
         label={_(msg`Remove attachment`)}
         onPress={onRemove}
         size="small"
         variant="solid"
         color="secondary"
-        shape="round">
+        shape="round"
+        style={[t.atoms.shadow_sm]}>
         <ButtonIcon icon={X} size="sm" />
       </Button>
     </View>
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index 4d2539c4e..ceee17eaa 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -6,23 +6,23 @@ import {useLingui} from '@lingui/react'
 import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
 import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
 import {
-  EmbedPlayerParams,
+  type EmbedPlayerParams,
   parseEmbedPlayerFromUrl,
 } from '#/lib/strings/embed-player'
 import {isAndroid} from '#/platform/detection'
 import {useResolveGifQuery} from '#/state/queries/resolve-link'
-import {Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/state/queries/tenor'
 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {DialogControlProps} from '#/components/Dialog'
+import {type DialogControlProps} from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
 import {Text} from '#/components/Typography'
-import {GifEmbed} from '../util/post-embeds/GifEmbed'
 import {AltTextReminder} from './photos/Gallery'
 
 export function GifAltTextDialog({
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 9548ed065..902d89b7b 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -4,10 +4,10 @@ import {useLingui} from '@lingui/react'
 
 import {
   ADULT_CONTENT_LABELS,
-  AdultSelfLabel,
+  type AdultSelfLabel,
   OTHER_SELF_LABELS,
-  OtherSelfLabel,
-  SelfLabel,
+  type OtherSelfLabel,
+  type SelfLabel,
 } from '#/lib/moderation'
 import {isWeb} from '#/platform/detection'
 import {atoms as a, native, useTheme, web} from '#/alf'
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index c0ce32af3..724149937 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {ImageStyle, useWindowDimensions, View} from 'react-native'
+import {type ImageStyle, useWindowDimensions, View} from 'react-native'
 import {Image} from 'expo-image'
 import {msg, Plural, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -7,12 +7,12 @@ import {useLingui} from '@lingui/react'
 import {MAX_ALT_TEXT} from '#/lib/constants'
 import {enforceLen} from '#/lib/strings/helpers'
 import {isAndroid, isWeb} from '#/platform/detection'
-import {ComposerImage} from '#/state/gallery'
+import {type ComposerImage} from '#/state/gallery'
 import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
-import {DialogControlProps} from '#/components/Dialog'
+import {type DialogControlProps} from '#/components/Dialog'
 import * as TextField from '#/components/forms/TextField'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {Text} from '#/components/Typography'
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 1c9440eb1..8bd1aa27b 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -8,7 +8,7 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
 import {openCamera} from '#/lib/media/picker'
 import {logger} from '#/logger'
 import {isMobileWeb, isNative} from '#/platform/detection'
-import {ComposerImage, createComposerImage} from '#/state/gallery'
+import {type ComposerImage, createComposerImage} from '#/state/gallery'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5184047cb..15f5539c9 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -46,7 +46,6 @@ import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
 import {Link, TextLink} from '#/view/com/util/Link'
 import {formatCount} from '#/view/com/util/numeric/format'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
@@ -62,6 +61,7 @@ import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {PostHider} from '#/components/moderation/PostHider'
 import {type AppModerationCause} from '#/components/Pills'
+import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
 import * as Prompt from '#/components/Prompt'
 import {RichText} from '#/components/RichText'
@@ -465,7 +465,7 @@ let PostThreadItemLoaded = ({
               ) : undefined}
               {post.embed && (
                 <View style={[a.py_xs]}>
-                  <PostEmbeds
+                  <Embed
                     embed={post.embed}
                     moderation={moderation}
                     viewContext={PostEmbedViewContext.ThreadHighlighted}
@@ -697,7 +697,7 @@ let PostThreadItemLoaded = ({
               ) : undefined}
               {post.embed && (
                 <View style={[a.pb_xs]}>
-                  <PostEmbeds
+                  <Embed
                     embed={post.embed}
                     moderation={moderation}
                     viewContext={PostEmbedViewContext.Feed}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 1a48d64d8..d92ea6a9d 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -28,7 +28,6 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {precacheProfile} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {Link, TextLink} from '#/view/com/util/Link'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {Text} from '#/view/com/util/text/Text'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
@@ -37,6 +36,7 @@ import {atoms as a} from '#/alf'
 import {ContentHider} from '#/components/moderation/ContentHider'
 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
 import {PostControls} from '#/components/PostControls'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
 import {RichText} from '#/components/RichText'
@@ -248,7 +248,7 @@ function PostInner({
               />
             ) : undefined}
             {post.embed ? (
-              <PostEmbeds
+              <Embed
                 embed={post.embed}
                 moderation={moderation}
                 viewContext={PostEmbedViewContext.Feed}
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
index fd0d1c707..a5a7a777e 100644
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -42,7 +42,6 @@ import {
 } from '#/state/unstable-post-source'
 import {FeedNameText} from '#/view/com/util/FeedInfoText'
 import {Link, TextLink, TextLinkOnWebOnly} from '#/view/com/util/Link'
-import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
 import {PostMeta} from '#/view/com/util/PostMeta'
 import {Text} from '#/view/com/util/text/Text'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
@@ -53,6 +52,8 @@ import {ContentHider} from '#/components/moderation/ContentHider'
 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {type AppModerationCause} from '#/components/Pills'
+import {Embed} from '#/components/Post/Embed'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
 import {PostControls} from '#/components/PostControls'
 import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug'
 import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -568,7 +569,7 @@ let PostContent = ({
       ) : undefined}
       {postEmbed ? (
         <View style={[a.pb_xs]}>
-          <PostEmbeds
+          <Embed
             embed={postEmbed}
             moderation={moderation}
             onOpen={onOpenEmbed}
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index 6a11c7eaa..9a3e8a4ae 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -14,12 +14,12 @@
 
 import React from 'react'
 import {
-  FlatList,
-  FlatListProps,
-  ScrollViewProps,
+  type FlatList,
+  type FlatListProps,
+  type ScrollViewProps,
   StyleSheet,
   View,
-  ViewProps,
+  type ViewProps,
 } from 'react-native'
 import Animated from 'react-native-reanimated'
 
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 1d35c88c5..323264ea4 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -8,9 +8,9 @@ import type React from 'react'
 
 import {type Dimensions} from '#/lib/media/types'
 import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
-import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
 import {atoms as a, useTheme} from '#/alf'
 import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
 import {Text} from '#/components/Typography'
 
 type EventFunction = (index: number) => void
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index b91d7a7ad..757d952a1 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -3,8 +3,8 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
 import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
 import {type AppBskyEmbedImages} from '@atproto/api'
 
-import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
 import {atoms as a, useBreakpoints} from '#/alf'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
 import {type Dimensions} from '../../lightbox/ImageViewing/@types'
 import {GalleryItem} from './Gallery'
 
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
deleted file mode 100644
index f788af1f8..000000000
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ /dev/null
@@ -1,337 +0,0 @@
-import React from 'react'
-import {
-  StyleProp,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-  ViewStyle,
-} from 'react-native'
-import {
-  AppBskyEmbedExternal,
-  AppBskyEmbedImages,
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  AppBskyEmbedVideo,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  moderatePost,
-  ModerationDecision,
-  RichText as RichTextAPI,
-} from '@atproto/api'
-import {AtUri} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
-
-import {HITSLOP_20} from '#/lib/constants'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {InfoCircleIcon} from '#/lib/icons'
-import {makeProfileLink} from '#/lib/routes/links'
-import {s} from '#/lib/styles'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {precacheProfile} from '#/state/queries/profile'
-import {useResolveLinkQuery} from '#/state/queries/resolve-link'
-import {useSession} from '#/state/session'
-import {atoms as a, useTheme} from '#/alf'
-import {RichText} from '#/components/RichText'
-import {SubtleWebHover} from '#/components/SubtleWebHover'
-import * as bsky from '#/types/bsky'
-import {ContentHider} from '../../../../components/moderation/ContentHider'
-import {PostAlerts} from '../../../../components/moderation/PostAlerts'
-import {Link} from '../Link'
-import {PostMeta} from '../PostMeta'
-import {Text} from '../text/Text'
-import {PostEmbeds} from '.'
-import {QuoteEmbedViewContext} from './types'
-
-export function MaybeQuoteEmbed({
-  embed,
-  onOpen,
-  style,
-  allowNestedQuotes,
-  viewContext,
-}: {
-  embed: AppBskyEmbedRecord.View
-  onOpen?: () => void
-  style?: StyleProp<ViewStyle>
-  allowNestedQuotes?: boolean
-  viewContext?: QuoteEmbedViewContext
-}) {
-  const t = useTheme()
-  const pal = usePalette('default')
-  const {currentAccount} = useSession()
-  if (
-    AppBskyEmbedRecord.isViewRecord(embed.record) &&
-    AppBskyFeedPost.isRecord(embed.record.value) &&
-    AppBskyFeedPost.validateRecord(embed.record.value).success
-  ) {
-    return (
-      <QuoteEmbedModerated
-        viewRecord={embed.record}
-        onOpen={onOpen}
-        style={style}
-        allowNestedQuotes={allowNestedQuotes}
-        viewContext={viewContext}
-      />
-    )
-  } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
-    return (
-      <View
-        style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
-        <InfoCircleIcon size={18} style={pal.text} />
-        <Text type="lg" style={pal.text}>
-          <Trans>Blocked</Trans>
-        </Text>
-      </View>
-    )
-  } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
-    return (
-      <View
-        style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
-        <InfoCircleIcon size={18} style={pal.text} />
-        <Text type="lg" style={pal.text}>
-          <Trans>Deleted</Trans>
-        </Text>
-      </View>
-    )
-  } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
-    const isViewerOwner = currentAccount?.did
-      ? embed.record.uri.includes(currentAccount.did)
-      : false
-    return (
-      <View
-        style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
-        <InfoCircleIcon size={18} style={pal.text} />
-        <Text type="lg" style={pal.text}>
-          {isViewerOwner ? (
-            <Trans>Removed by you</Trans>
-          ) : (
-            <Trans>Removed by author</Trans>
-          )}
-        </Text>
-      </View>
-    )
-  }
-  return null
-}
-
-function QuoteEmbedModerated({
-  viewRecord,
-  onOpen,
-  style,
-  allowNestedQuotes,
-  viewContext,
-}: {
-  viewRecord: AppBskyEmbedRecord.ViewRecord
-  onOpen?: () => void
-  style?: StyleProp<ViewStyle>
-  allowNestedQuotes?: boolean
-  viewContext?: QuoteEmbedViewContext
-}) {
-  const moderationOpts = useModerationOpts()
-  const postView = React.useMemo(
-    () => viewRecordToPostView(viewRecord),
-    [viewRecord],
-  )
-  const moderation = React.useMemo(() => {
-    return moderationOpts ? moderatePost(postView, moderationOpts) : undefined
-  }, [postView, moderationOpts])
-
-  return (
-    <QuoteEmbed
-      quote={postView}
-      moderation={moderation}
-      onOpen={onOpen}
-      style={style}
-      allowNestedQuotes={allowNestedQuotes}
-      viewContext={viewContext}
-    />
-  )
-}
-
-export function QuoteEmbed({
-  quote,
-  moderation,
-  onOpen,
-  style,
-  allowNestedQuotes,
-}: {
-  quote: AppBskyFeedDefs.PostView
-  moderation?: ModerationDecision
-  onOpen?: () => void
-  style?: StyleProp<ViewStyle>
-  allowNestedQuotes?: boolean
-  viewContext?: QuoteEmbedViewContext
-}) {
-  const t = useTheme()
-  const queryClient = useQueryClient()
-  const pal = usePalette('default')
-  const itemUrip = new AtUri(quote.uri)
-  const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
-  const itemTitle = `Post by ${quote.author.handle}`
-
-  const richText = React.useMemo(() => {
-    if (
-      !bsky.dangerousIsType<AppBskyFeedPost.Record>(
-        quote.record,
-        AppBskyFeedPost.isRecord,
-      )
-    )
-      return undefined
-    const {text, facets} = quote.record
-    return text.trim()
-      ? new RichTextAPI({text: text, facets: facets})
-      : undefined
-  }, [quote.record])
-
-  const embed = React.useMemo(() => {
-    const e = quote.embed
-
-    if (allowNestedQuotes) {
-      return e
-    } else {
-      if (
-        AppBskyEmbedImages.isView(e) ||
-        AppBskyEmbedExternal.isView(e) ||
-        AppBskyEmbedVideo.isView(e)
-      ) {
-        return e
-      } else if (
-        AppBskyEmbedRecordWithMedia.isView(e) &&
-        (AppBskyEmbedImages.isView(e.media) ||
-          AppBskyEmbedExternal.isView(e.media) ||
-          AppBskyEmbedVideo.isView(e.media))
-      ) {
-        return e.media
-      }
-    }
-  }, [quote.embed, allowNestedQuotes])
-
-  const onBeforePress = React.useCallback(() => {
-    precacheProfile(queryClient, quote.author)
-    onOpen?.()
-  }, [queryClient, quote.author, onOpen])
-
-  const [hover, setHover] = React.useState(false)
-  return (
-    <View
-      onPointerEnter={() => {
-        setHover(true)
-      }}
-      onPointerLeave={() => {
-        setHover(false)
-      }}>
-      <ContentHider
-        modui={moderation?.ui('contentList')}
-        style={[
-          a.rounded_md,
-          a.p_md,
-          a.mt_sm,
-          a.border,
-          t.atoms.border_contrast_low,
-          style,
-        ]}
-        childContainerStyle={[a.pt_sm]}>
-        <SubtleWebHover hover={hover} />
-        <Link
-          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
-          href={itemHref}
-          title={itemTitle}
-          onBeforePress={onBeforePress}>
-          <View pointerEvents="none">
-            <PostMeta
-              author={quote.author}
-              moderation={moderation}
-              showAvatar
-              postHref={itemHref}
-              timestamp={quote.indexedAt}
-            />
-          </View>
-          {moderation ? (
-            <PostAlerts
-              modui={moderation.ui('contentView')}
-              style={[a.py_xs]}
-            />
-          ) : null}
-          {richText ? (
-            <RichText
-              value={richText}
-              style={a.text_md}
-              numberOfLines={20}
-              disableLinks
-            />
-          ) : null}
-          {embed && <PostEmbeds embed={embed} moderation={moderation} />}
-        </Link>
-      </ContentHider>
-    </View>
-  )
-}
-
-export function QuoteX({onRemove}: {onRemove: () => void}) {
-  const {_} = useLingui()
-  return (
-    <TouchableOpacity
-      style={[
-        a.absolute,
-        a.p_xs,
-        a.rounded_full,
-        a.align_center,
-        a.justify_center,
-        {
-          top: 16,
-          right: 10,
-          backgroundColor: 'rgba(0, 0, 0, 0.75)',
-        },
-      ]}
-      onPress={onRemove}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`Remove quote`)}
-      accessibilityHint={_(msg`Removes quoted post`)}
-      onAccessibilityEscape={onRemove}
-      hitSlop={HITSLOP_20}>
-      <FontAwesomeIcon size={12} icon="xmark" style={s.white} />
-    </TouchableOpacity>
-  )
-}
-
-export function LazyQuoteEmbed({uri}: {uri: string}) {
-  const {data} = useResolveLinkQuery(uri)
-  const moderationOpts = useModerationOpts()
-  if (!data || data.type !== 'record' || data.kind !== 'post') {
-    return null
-  }
-  const moderation = moderationOpts
-    ? moderatePost(data.view, moderationOpts)
-    : undefined
-  return <QuoteEmbed quote={data.view} moderation={moderation} />
-}
-
-function viewRecordToPostView(
-  viewRecord: AppBskyEmbedRecord.ViewRecord,
-): AppBskyFeedDefs.PostView {
-  const {value, embeds, ...rest} = viewRecord
-  return {
-    ...rest,
-    $type: 'app.bsky.feed.defs#postView',
-    record: value,
-    embed: embeds?.[0],
-  }
-}
-
-const styles = StyleSheet.create({
-  errorContainer: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 4,
-    borderRadius: 8,
-    marginTop: 8,
-    paddingVertical: 14,
-    paddingHorizontal: 14,
-    borderWidth: StyleSheet.hairlineWidth,
-  },
-  alert: {
-    marginBottom: 6,
-  },
-})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
deleted file mode 100644
index 4cf71f948..000000000
--- a/src/view/com/util/post-embeds/index.tsx
+++ /dev/null
@@ -1,327 +0,0 @@
-import React from 'react'
-import {
-  InteractionManager,
-  type StyleProp,
-  StyleSheet,
-  View,
-  type ViewStyle,
-} from 'react-native'
-import {
-  type AnimatedRef,
-  measure,
-  type MeasuredDimensions,
-  runOnJS,
-  runOnUI,
-} from 'react-native-reanimated'
-import {Image} from 'expo-image'
-import {
-  AppBskyEmbedExternal,
-  AppBskyEmbedImages,
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  AppBskyEmbedVideo,
-  AppBskyFeedDefs,
-  AppBskyGraphDefs,
-  moderateFeedGenerator,
-  moderateUserList,
-  type ModerationDecision,
-} from '@atproto/api'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useLightboxControls} from '#/state/lightbox'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {FeedSourceCard} from '#/view/com/feeds/FeedSourceCard'
-import {atoms as a, useTheme} from '#/alf'
-import * as ListCard from '#/components/ListCard'
-import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
-import {ContentHider} from '../../../../components/moderation/ContentHider'
-import {type Dimensions} from '../../lightbox/ImageViewing/@types'
-import {AutoSizedImage} from '../images/AutoSizedImage'
-import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
-import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {MaybeQuoteEmbed} from './QuoteEmbed'
-import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
-import {VideoEmbed} from './VideoEmbed'
-
-export * from './types'
-
-type Embed =
-  | AppBskyEmbedRecord.View
-  | AppBskyEmbedImages.View
-  | AppBskyEmbedVideo.View
-  | AppBskyEmbedExternal.View
-  | AppBskyEmbedRecordWithMedia.View
-  | {$type: string; [k: string]: unknown}
-
-export function PostEmbeds({
-  embed,
-  moderation,
-  onOpen,
-  style,
-  allowNestedQuotes,
-  viewContext,
-}: {
-  embed?: Embed
-  moderation?: ModerationDecision
-  onOpen?: () => void
-  style?: StyleProp<ViewStyle>
-  allowNestedQuotes?: boolean
-  viewContext?: PostEmbedViewContext
-}) {
-  const {openLightbox} = useLightboxControls()
-
-  // quote post with media
-  // =
-  if (AppBskyEmbedRecordWithMedia.isView(embed)) {
-    return (
-      <View style={style}>
-        <PostEmbeds
-          embed={embed.media}
-          moderation={moderation}
-          onOpen={onOpen}
-          viewContext={viewContext}
-        />
-        <MaybeQuoteEmbed
-          embed={embed.record}
-          onOpen={onOpen}
-          viewContext={
-            viewContext === PostEmbedViewContext.Feed
-              ? QuoteEmbedViewContext.FeedEmbedRecordWithMedia
-              : undefined
-          }
-        />
-      </View>
-    )
-  }
-
-  if (AppBskyEmbedRecord.isView(embed)) {
-    // custom feed embed (i.e. generator view)
-    if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
-      return (
-        <View style={a.mt_sm}>
-          <MaybeFeedCard view={embed.record} />
-        </View>
-      )
-    }
-
-    // list embed
-    if (AppBskyGraphDefs.isListView(embed.record)) {
-      return (
-        <View style={a.mt_sm}>
-          <MaybeListCard view={embed.record} />
-        </View>
-      )
-    }
-
-    // starter pack embed
-    if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
-      return (
-        <View style={a.mt_sm}>
-          <StarterPackCard starterPack={embed.record} />
-        </View>
-      )
-    }
-
-    // quote post
-    // =
-    return (
-      <MaybeQuoteEmbed
-        embed={embed}
-        style={style}
-        onOpen={onOpen}
-        allowNestedQuotes={allowNestedQuotes}
-      />
-    )
-  }
-
-  // image embed
-  // =
-  if (AppBskyEmbedImages.isView(embed)) {
-    const {images} = embed
-
-    if (images.length > 0) {
-      const items = embed.images.map(img => ({
-        uri: img.fullsize,
-        thumbUri: img.thumb,
-        alt: img.alt,
-        dimensions: img.aspectRatio ?? null,
-      }))
-      const _openLightbox = (
-        index: number,
-        thumbRects: (MeasuredDimensions | null)[],
-        fetchedDims: (Dimensions | null)[],
-      ) => {
-        openLightbox({
-          images: items.map((item, i) => ({
-            ...item,
-            thumbRect: thumbRects[i] ?? null,
-            thumbDimensions: fetchedDims[i] ?? null,
-            type: 'image',
-          })),
-          index,
-        })
-      }
-      const onPress = (
-        index: number,
-        refs: AnimatedRef<any>[],
-        fetchedDims: (Dimensions | null)[],
-      ) => {
-        runOnUI(() => {
-          'worklet'
-          const rects: (MeasuredDimensions | null)[] = []
-          for (const r of refs) {
-            rects.push(measure(r))
-          }
-          runOnJS(_openLightbox)(index, rects, fetchedDims)
-        })()
-      }
-      const onPressIn = (_: number) => {
-        InteractionManager.runAfterInteractions(() => {
-          Image.prefetch(items.map(i => i.uri))
-        })
-      }
-
-      if (images.length === 1) {
-        const image = images[0]
-        return (
-          <ContentHider modui={moderation?.ui('contentMedia')}>
-            <View style={[a.mt_sm, style]}>
-              <AutoSizedImage
-                crop={
-                  viewContext === PostEmbedViewContext.ThreadHighlighted
-                    ? 'none'
-                    : viewContext ===
-                      PostEmbedViewContext.FeedEmbedRecordWithMedia
-                    ? 'square'
-                    : 'constrained'
-                }
-                image={image}
-                onPress={(containerRef, dims) =>
-                  onPress(0, [containerRef], [dims])
-                }
-                onPressIn={() => onPressIn(0)}
-                hideBadge={
-                  viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
-                }
-              />
-            </View>
-          </ContentHider>
-        )
-      }
-
-      return (
-        <ContentHider modui={moderation?.ui('contentMedia')}>
-          <View style={[a.mt_sm, style]}>
-            <ImageLayoutGrid
-              images={embed.images}
-              onPress={onPress}
-              onPressIn={onPressIn}
-              viewContext={viewContext}
-            />
-          </View>
-        </ContentHider>
-      )
-    }
-  }
-
-  // external link embed
-  // =
-  if (AppBskyEmbedExternal.isView(embed)) {
-    const link = embed.external
-    return (
-      <ContentHider modui={moderation?.ui('contentMedia')}>
-        <ExternalLinkEmbed
-          link={link}
-          onOpen={onOpen}
-          style={[a.mt_sm, style]}
-        />
-      </ContentHider>
-    )
-  }
-
-  // video embed
-  // =
-  if (AppBskyEmbedVideo.isView(embed)) {
-    return (
-      <ContentHider modui={moderation?.ui('contentMedia')}>
-        <VideoEmbed
-          embed={embed}
-          crop={
-            viewContext === PostEmbedViewContext.ThreadHighlighted
-              ? 'none'
-              : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
-              ? 'square'
-              : 'constrained'
-          }
-        />
-      </ContentHider>
-    )
-  }
-
-  return <View />
-}
-
-export function MaybeFeedCard({view}: {view: AppBskyFeedDefs.GeneratorView}) {
-  const pal = usePalette('default')
-  const moderationOpts = useModerationOpts()
-  const moderation = React.useMemo(() => {
-    return moderationOpts
-      ? moderateFeedGenerator(view, moderationOpts)
-      : undefined
-  }, [view, moderationOpts])
-
-  return (
-    <ContentHider modui={moderation?.ui('contentList')}>
-      <FeedSourceCard
-        feedUri={view.uri}
-        style={[pal.view, pal.border, styles.customFeedOuter]}
-        showLikes
-      />
-    </ContentHider>
-  )
-}
-
-export function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
-  const moderationOpts = useModerationOpts()
-  const moderation = React.useMemo(() => {
-    return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
-  }, [view, moderationOpts])
-  const t = useTheme()
-
-  return (
-    <ContentHider modui={moderation?.ui('contentList')}>
-      <View
-        style={[
-          a.border,
-          t.atoms.border_contrast_medium,
-          a.p_md,
-          a.rounded_sm,
-        ]}>
-        <ListCard.Default view={view} />
-      </View>
-    </ContentHider>
-  )
-}
-
-const styles = StyleSheet.create({
-  altContainer: {
-    backgroundColor: 'rgba(0, 0, 0, 0.75)',
-    borderRadius: 6,
-    paddingHorizontal: 6,
-    paddingVertical: 3,
-    position: 'absolute',
-    right: 6,
-    bottom: 6,
-  },
-  alt: {
-    color: 'white',
-    fontSize: 7,
-    fontWeight: '600',
-  },
-  customFeedOuter: {
-    borderWidth: StyleSheet.hairlineWidth,
-    borderRadius: 8,
-    paddingHorizontal: 12,
-    paddingVertical: 12,
-  },
-})
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
deleted file mode 100644
index 08e903276..000000000
--- a/src/view/com/util/post-embeds/types.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export enum PostEmbedViewContext {
-  ThreadHighlighted = 'ThreadHighlighted',
-  Feed = 'Feed',
-  FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
-}
-
-export enum QuoteEmbedViewContext {
-  FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
-}