about summary refs log tree commit diff
path: root/src/view/com/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/util')
-rw-r--r--src/view/com/util/Html.tsx4
-rw-r--r--src/view/com/util/List.tsx45
-rw-r--r--src/view/com/util/PostMeta.tsx4
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx241
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx29
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx3
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx18
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx251
-rw-r--r--src/view/com/util/post-embeds/YoutubeEmbed.tsx55
-rw-r--r--src/view/com/util/post-embeds/index.tsx18
10 files changed, 591 insertions, 77 deletions
diff --git a/src/view/com/util/Html.tsx b/src/view/com/util/Html.tsx
index 1590955a2..2e4719481 100644
--- a/src/view/com/util/Html.tsx
+++ b/src/view/com/util/Html.tsx
@@ -30,6 +30,7 @@ export function H1({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-xl']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
 }
 
@@ -37,6 +38,7 @@ export function H2({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-lg']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
 }
 
@@ -44,6 +46,7 @@ export function H3({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography.title
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
 }
 
@@ -51,6 +54,7 @@ export function H4({children}: React.PropsWithChildren<{}>) {
   const styles = useStyles()
   const pal = usePalette('default')
   const typography = useTheme().typography['title-sm']
+  // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
   return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
 }
 
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 2acc3f4b3..9abd7d35a 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,27 +1,42 @@
 import React, {memo, startTransition} from 'react'
-import {FlatListProps} from 'react-native'
+import {FlatListProps, RefreshControl} from 'react-native'
 import {FlatList_INTERNAL} from './Views'
+import {addStyle} from 'lib/styles'
 import {useScrollHandlers} from '#/lib/ScrollContext'
 import {runOnJS, useSharedValue} from 'react-native-reanimated'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {usePalette} from '#/lib/hooks/usePalette'
 
 export type ListMethods = FlatList_INTERNAL
 export type ListProps<ItemT> = Omit<
   FlatListProps<ItemT>,
-  'onScroll' // Use ScrollContext instead.
+  | 'onScroll' // Use ScrollContext instead.
+  | 'refreshControl' // Pass refreshing and/or onRefresh instead.
+  | 'contentOffset' // Pass headerOffset instead.
 > & {
   onScrolledDownChange?: (isScrolledDown: boolean) => void
+  headerOffset?: number
+  refreshing?: boolean
+  onRefresh?: () => void
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
 const SCROLLED_DOWN_LIMIT = 200
 
 function ListImpl<ItemT>(
-  {onScrolledDownChange, ...props}: ListProps<ItemT>,
+  {
+    onScrolledDownChange,
+    refreshing,
+    onRefresh,
+    headerOffset,
+    style,
+    ...props
+  }: ListProps<ItemT>,
   ref: React.Ref<ListMethods>,
 ) {
   const isScrolledDown = useSharedValue(false)
   const contextScrollHandlers = useScrollHandlers()
+  const pal = usePalette('default')
 
   function handleScrolledDownChange(didScrollDown: boolean) {
     startTransition(() => {
@@ -49,12 +64,36 @@ function ListImpl<ItemT>(
     },
   })
 
+  let refreshControl
+  if (refreshing !== undefined || onRefresh !== undefined) {
+    refreshControl = (
+      <RefreshControl
+        refreshing={refreshing ?? false}
+        onRefresh={onRefresh}
+        tintColor={pal.colors.text}
+        titleColor={pal.colors.text}
+        progressViewOffset={headerOffset}
+      />
+    )
+  }
+
+  let contentOffset
+  if (headerOffset != null) {
+    style = addStyle(style, {
+      paddingTop: headerOffset,
+    })
+    contentOffset = {x: 0, y: headerOffset * -1}
+  }
+
   return (
     <FlatList_INTERNAL
       {...props}
       scrollIndicatorInsets={{right: 1}}
+      contentOffset={contentOffset}
+      refreshControl={refreshControl}
       onScroll={scrollHandler}
       scrollEventThrottle={1}
+      style={style}
       ref={ref}
     />
   )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index eef7094cd..b9c3842b3 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {isAndroid} from 'platform/detection'
 import {TimeElapsed} from './TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
+import {ModerationUI} from '@atproto/api'
 
 interface PostMetaOpts {
   author: {
@@ -23,6 +24,7 @@ interface PostMetaOpts {
   postHref: string
   timestamp: string
   showAvatar?: boolean
+  avatarModeration?: ModerationUI
   avatarSize?: number
   displayNameType?: TypographyVariant
   displayNameStyle?: StyleProp<TextStyle>
@@ -41,7 +43,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
           <UserAvatar
             avatar={opts.author.avatar}
             size={opts.avatarSize || 16}
-            // TODO moderation
+            moderation={opts.avatarModeration}
           />
         </View>
       )}
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
new file mode 100644
index 000000000..9e9888ad8
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -0,0 +1,241 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {Pressable, StyleSheet, View, Text} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuContent = DropdownMenu.Content
+
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
+  const theme = useTheme()
+  const [focused, setFocused] = React.useState(false)
+  const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
+
+  return (
+    <DropdownMenu.Item
+      {...props}
+      style={StyleSheet.flatten([
+        styles.item,
+        focused && {backgroundColor: backgroundColor},
+      ])}
+      onFocus={() => {
+        setFocused(true)
+      }}
+      onBlur={() => {
+        setFocused(false)
+      }}
+    />
+  )
+}
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+  label: string | 'separator'
+  onPress?: () => void
+  testID?: string
+  icon?: {
+    ios: MenuItemCommonProps['ios']
+    android: string
+    web: IconProp
+  }
+}
+type Props = {
+  items: DropdownItem[]
+  testID?: string
+  accessibilityLabel?: string
+  accessibilityHint?: string
+}
+
+export function NativeDropdown({
+  items,
+  children,
+  testID,
+  accessibilityLabel,
+  accessibilityHint,
+}: React.PropsWithChildren<Props>) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const dropDownBackgroundColor =
+    theme.colorScheme === 'dark' ? pal.btn : pal.view
+  const [open, setOpen] = React.useState(false)
+  const buttonRef = React.useRef<HTMLButtonElement>(null)
+  const menuRef = React.useRef<HTMLDivElement>(null)
+  const {borderColor: separatorColor} =
+    theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+  React.useEffect(() => {
+    function clickHandler(e: MouseEvent) {
+      const t = e.target
+
+      if (!open) return
+      if (!t) return
+      if (!buttonRef.current || !menuRef.current) return
+
+      if (
+        t !== buttonRef.current &&
+        !buttonRef.current.contains(t as Node) &&
+        t !== menuRef.current &&
+        !menuRef.current.contains(t as Node)
+      ) {
+        // prevent clicking through to links beneath dropdown
+        // only applies to mobile web
+        e.preventDefault()
+        e.stopPropagation()
+
+        // close menu
+        setOpen(false)
+      }
+    }
+
+    function keydownHandler(e: KeyboardEvent) {
+      if (e.key === 'Escape' && open) {
+        setOpen(false)
+      }
+    }
+
+    document.addEventListener('click', clickHandler, true)
+    window.addEventListener('keydown', keydownHandler, true)
+    return () => {
+      document.removeEventListener('click', clickHandler, true)
+      window.removeEventListener('keydown', keydownHandler, true)
+    }
+  }, [open, setOpen])
+
+  return (
+    <DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
+      <DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
+        <Pressable
+          ref={buttonRef as unknown as React.Ref<View>}
+          testID={testID}
+          accessibilityRole="button"
+          accessibilityLabel={accessibilityLabel}
+          accessibilityHint={accessibilityHint}
+          onPress={() => setOpen(o => !o)}
+          hitSlop={HITSLOP_10}>
+          {children}
+        </Pressable>
+      </DropdownMenu.Trigger>
+
+      <DropdownMenu.Portal>
+        <DropdownMenu.Content
+          ref={menuRef}
+          style={
+            StyleSheet.flatten([
+              styles.content,
+              dropDownBackgroundColor,
+            ]) as React.CSSProperties
+          }
+          loop>
+          {items.map((item, index) => {
+            if (item.label === 'separator') {
+              return (
+                <DropdownMenu.Separator
+                  key={getKey(item.label, index, item.testID)}
+                  style={
+                    StyleSheet.flatten([
+                      styles.separator,
+                      {backgroundColor: separatorColor},
+                    ]) as React.CSSProperties
+                  }
+                />
+              )
+            }
+            if (index > 1 && items[index - 1].label === 'separator') {
+              return (
+                <DropdownMenu.Group
+                  key={getKey(item.label, index, item.testID)}>
+                  <DropdownMenuItem
+                    key={getKey(item.label, index, item.testID)}
+                    onSelect={item.onPress}>
+                    <Text
+                      selectable={false}
+                      style={[pal.text, styles.itemTitle]}>
+                      {item.label}
+                    </Text>
+                    {item.icon && (
+                      <FontAwesomeIcon
+                        icon={item.icon.web}
+                        size={20}
+                        color={pal.colors.textLight}
+                      />
+                    )}
+                  </DropdownMenuItem>
+                </DropdownMenu.Group>
+              )
+            }
+            return (
+              <DropdownMenuItem
+                key={getKey(item.label, index, item.testID)}
+                onSelect={item.onPress}>
+                <Text selectable={false} style={[pal.text, styles.itemTitle]}>
+                  {item.label}
+                </Text>
+                {item.icon && (
+                  <FontAwesomeIcon
+                    icon={item.icon.web}
+                    size={20}
+                    color={pal.colors.textLight}
+                  />
+                )}
+              </DropdownMenuItem>
+            )
+          })}
+        </DropdownMenu.Content>
+      </DropdownMenu.Portal>
+    </DropdownMenuRoot>
+  )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+  if (id) {
+    return id
+  }
+  return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+  separator: {
+    height: 1,
+    marginTop: 4,
+    marginBottom: 4,
+  },
+  content: {
+    backgroundColor: '#f0f0f0',
+    borderRadius: 8,
+    paddingTop: 4,
+    paddingBottom: 4,
+    paddingLeft: 4,
+    paddingRight: 4,
+    marginTop: 6,
+
+    // @ts-ignore web only -prf
+    boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
+  },
+  item: {
+    display: 'flex',
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    columnGap: 20,
+    // @ts-ignore -web
+    cursor: 'pointer',
+    paddingTop: 8,
+    paddingBottom: 8,
+    paddingLeft: 12,
+    paddingRight: 12,
+    borderRadius: 8,
+  },
+  itemTitle: {
+    fontSize: 16,
+    fontWeight: '500',
+    paddingRight: 10,
+  },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 193bb9bd7..1f2e067c2 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -18,6 +18,7 @@ import {getTranslatorLink} from '#/locale/helpers'
 import {usePostDeleteMutation} from '#/state/queries/post'
 import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
+import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
 import {logger} from '#/logger'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -50,9 +51,12 @@ let PostDropdownBtn = ({
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
   const postDeleteMutation = usePostDeleteMutation()
+  const hiddenPosts = useHiddenPosts()
+  const {hidePost} = useHiddenPostsApi()
 
   const rootUri = record.reply?.root?.uri || postUri
   const isThreadMuted = mutedThreads.includes(rootUri)
+  const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
   const isAuthor = postAuthor.did === currentAccount?.did
   const href = React.useMemo(() => {
     const urip = new AtUri(postUri)
@@ -98,6 +102,10 @@ let PostDropdownBtn = ({
     Linking.openURL(translatorUrl)
   }, [translatorUrl])
 
+  const onHidePost = React.useCallback(() => {
+    hidePost({uri: postUri})
+  }, [postUri, hidePost])
+
   const dropdownItems: NativeDropdownItem[] = [
     {
       label: _(msg`Translate`),
@@ -159,6 +167,27 @@ let PostDropdownBtn = ({
         web: 'comment-slash',
       },
     },
+    hasSession &&
+      !isAuthor &&
+      !isPostHidden && {
+        label: _(msg`Hide post`),
+        onPress() {
+          openModal({
+            name: 'confirm',
+            title: _(msg`Hide this post?`),
+            message: _(msg`This will hide this post from your feeds.`),
+            onPressConfirm: onHidePost,
+          })
+        },
+        testID: 'postDropdownHideBtn',
+        icon: {
+          ios: {
+            name: 'eye.slash',
+          },
+          android: 'ic_menu_delete',
+          web: ['far', 'eye-slash'],
+        },
+      },
     {
       label: 'separator',
     },
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 414fb1e09..a50b52175 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -31,12 +31,14 @@ let PostCtrls = ({
   big,
   post,
   record,
+  showAppealLabelItem,
   style,
   onPressReply,
 }: {
   big?: boolean
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
+  showAppealLabelItem?: boolean
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
 }): React.ReactNode => {
@@ -207,6 +209,7 @@ let PostCtrls = ({
           postCid={post.cid}
           postUri={post.uri}
           record={record}
+          showAppealLabelItem={showAppealLabelItem}
           style={styles.ctrlPad}
         />
       )}
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index d5bb38fb2..27aa804d3 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -6,22 +6,28 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {AppBskyEmbedExternal} from '@atproto/api'
 import {toNiceDomain} from 'lib/strings/url-helpers'
+import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
+import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
 
 export const ExternalLinkEmbed = ({
   link,
-  imageChild,
 }: {
   link: AppBskyEmbedExternal.ViewExternal
-  imageChild?: React.ReactNode
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+
+  const embedPlayerParams = React.useMemo(
+    () => parseEmbedPlayerFromUrl(link.uri),
+    [link.uri],
+  )
+
   return (
     <View
       style={{
-        flexDirection: isMobile ? 'column' : 'row',
+        flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
       }}>
-      {link.thumb ? (
+      {link.thumb && !embedPlayerParams ? (
         <View
           style={
             !isMobile
@@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({
             source={{uri: link.thumb}}
             accessibilityIgnoresInvertColors
           />
-          {imageChild}
         </View>
       ) : undefined}
+      {embedPlayerParams && (
+        <ExternalPlayer link={link} params={embedPlayerParams} />
+      )}
       <View
         style={{
           paddingHorizontal: isMobile ? 10 : 14,
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
new file mode 100644
index 000000000..580cf363a
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -0,0 +1,251 @@
+import React from 'react'
+import {
+  ActivityIndicator,
+  Dimensions,
+  GestureResponderEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {Image} from 'expo-image'
+import {WebView} from 'react-native-webview'
+import YoutubePlayer from 'react-native-youtube-iframe'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
+import {EventStopper} from '../EventStopper'
+import {AppBskyEmbedExternal} from '@atproto/api'
+import {isNative} from 'platform/detection'
+import {useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
+
+interface ShouldStartLoadRequest {
+  url: string
+}
+
+// This renders the overlay when the player is either inactive or loading as a separate layer
+function PlaceholderOverlay({
+  isLoading,
+  isPlayerActive,
+  onPress,
+}: {
+  isLoading: boolean
+  isPlayerActive: boolean
+  onPress: (event: GestureResponderEvent) => void
+}) {
+  // If the player is active and not loading, we don't want to show the overlay.
+  if (isPlayerActive && !isLoading) return null
+
+  return (
+    <View style={[styles.layer, styles.overlayLayer]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Play Video"
+        accessibilityHint=""
+        onPress={onPress}
+        style={[styles.overlayContainer, styles.topRadius]}>
+        {!isPlayerActive ? (
+          <FontAwesomeIcon icon="play" size={42} color="white" />
+        ) : (
+          <ActivityIndicator size="large" color="white" />
+        )}
+      </Pressable>
+    </View>
+  )
+}
+
+// This renders the webview/youtube player as a separate layer
+function Player({
+  height,
+  params,
+  onLoad,
+  isPlayerActive,
+}: {
+  isPlayerActive: boolean
+  params: EmbedPlayerParams
+  height: number
+  onLoad: () => void
+}) {
+  // ensures we only load what's requested
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) => event.url === params.playerUri,
+    [params.playerUri],
+  )
+
+  // Don't show the player until it is active
+  if (!isPlayerActive) return null
+
+  return (
+    <View style={[styles.layer, styles.playerLayer]}>
+      <EventStopper>
+        {isNative && params.type === 'youtube_video' ? (
+          <YoutubePlayer
+            videoId={params.videoId}
+            play
+            height={height}
+            onReady={onLoad}
+            webViewStyle={[styles.webview, styles.topRadius]}
+          />
+        ) : (
+          <View style={{height, width: '100%'}}>
+            <WebView
+              javaScriptEnabled={true}
+              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+              mediaPlaybackRequiresUserAction={false}
+              allowsInlineMediaPlayback
+              bounces={false}
+              allowsFullscreenVideo
+              nestedScrollEnabled
+              source={{uri: params.playerUri}}
+              onLoad={onLoad}
+              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+              style={[styles.webview, styles.topRadius]}
+            />
+          </View>
+        )}
+      </EventStopper>
+    </View>
+  )
+}
+
+// This renders the player area and handles the logic for when to show the player and when to show the overlay
+export function ExternalPlayer({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const navigation = useNavigation<NavigationProp>()
+
+  const [isPlayerActive, setPlayerActive] = React.useState(false)
+  const [isLoading, setIsLoading] = React.useState(true)
+  const [dim, setDim] = React.useState({
+    width: 0,
+    height: 0,
+  })
+
+  const viewRef = React.useRef<View>(null)
+
+  // watch for leaving the viewport due to scrolling
+  React.useEffect(() => {
+    // Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
+    // continue playing. We need to watch for the blur event
+    const unsubscribe = navigation.addListener('blur', () => {
+      setPlayerActive(false)
+    })
+
+    const interval = setInterval(() => {
+      viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
+        const window = Dimensions.get('window')
+        const top = pageY
+        const bot = pageY + h
+        const isVisible = isNative
+          ? top >= 0 && bot <= window.height
+          : !(top >= window.height || bot <= 0)
+        if (!isVisible) {
+          setPlayerActive(false)
+        }
+      })
+    }, 1e3)
+    return () => {
+      unsubscribe()
+      clearInterval(interval)
+    }
+  }, [viewRef, navigation])
+
+  // calculate height for the player and the screen size
+  const height = React.useMemo(
+    () =>
+      getPlayerHeight({
+        type: params.type,
+        width: dim.width,
+        hasThumb: !!link.thumb,
+      }),
+    [params.type, dim.width, link.thumb],
+  )
+
+  const onLoad = React.useCallback(() => {
+    setIsLoading(false)
+  }, [])
+
+  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
+    // Prevent this from propagating upward on web
+    event.preventDefault()
+
+    setPlayerActive(true)
+  }, [])
+
+  // measure the layout to set sizing
+  const onLayout = React.useCallback(
+    (event: {nativeEvent: {layout: {width: any; height: any}}}) => {
+      setDim({
+        width: event.nativeEvent.layout.width,
+        height: event.nativeEvent.layout.height,
+      })
+    },
+    [],
+  )
+
+  return (
+    <View
+      ref={viewRef}
+      style={{height}}
+      collapsable={false}
+      onLayout={onLayout}>
+      {link.thumb && (!isPlayerActive || isLoading) && (
+        <Image
+          style={[
+            {
+              width: dim.width,
+              height,
+            },
+            styles.topRadius,
+          ]}
+          source={{uri: link.thumb}}
+          accessibilityIgnoresInvertColors
+        />
+      )}
+
+      <PlaceholderOverlay
+        isLoading={isLoading}
+        isPlayerActive={isPlayerActive}
+        onPress={onPlayPress}
+      />
+      <Player
+        isPlayerActive={isPlayerActive}
+        params={params}
+        height={height}
+        onLoad={onLoad}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  playerLayer: {
+    zIndex: 3,
+  },
+  webview: {
+    backgroundColor: 'transparent',
+  },
+})
diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
deleted file mode 100644
index 2f2da5662..000000000
--- a/src/view/com/util/post-embeds/YoutubeEmbed.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {AppBskyEmbedExternal} from '@atproto/api'
-import {Link} from '../Link'
-
-export const YoutubeEmbed = ({
-  link,
-  style,
-}: {
-  link: AppBskyEmbedExternal.ViewExternal
-  style?: StyleProp<ViewStyle>
-}) => {
-  const pal = usePalette('default')
-
-  const imageChild = (
-    <View style={styles.playButton}>
-      <FontAwesomeIcon icon="play" size={24} color="white" />
-    </View>
-  )
-
-  return (
-    <Link
-      asAnchor
-      style={[styles.extOuter, pal.view, pal.border, style]}
-      href={link.uri}>
-      <ExternalLinkEmbed link={link} imageChild={imageChild} />
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  extOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-  },
-  playButton: {
-    position: 'absolute',
-    alignSelf: 'center',
-    alignItems: 'center',
-    top: '44%',
-    justifyContent: 'center',
-    backgroundColor: 'black',
-    padding: 10,
-    borderRadius: 50,
-    opacity: 0.8,
-  },
-  webView: {
-    alignItems: 'center',
-    alignContent: 'center',
-    justifyContent: 'center',
-  },
-})
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 2814cad87..c94ce9684 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
 import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {YoutubeEmbed} from './YoutubeEmbed'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ListEmbed} from './ListEmbed'
@@ -168,19 +166,13 @@ export function PostEmbeds({
   // =
   if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
-    const youtubeVideoId = getYoutubeVideoId(link.uri)
-
-    if (youtubeVideoId) {
-      return <YoutubeEmbed link={link} style={style} />
-    }
 
     return (
-      <Link
-        asAnchor
-        style={[styles.extOuter, pal.view, pal.border, style]}
-        href={link.uri}>
-        <ExternalLinkEmbed link={link} />
-      </Link>
+      <View style={[styles.extOuter, pal.view, pal.border, style]}>
+        <Link asAnchor href={link.uri}>
+          <ExternalLinkEmbed link={link} />
+        </Link>
+      </View>
     )
   }