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/Link.tsx11
-rw-r--r--src/view/com/util/PostCtrls.tsx40
-rw-r--r--src/view/com/util/PostEmbeds/YoutubeEmbed.tsx119
-rw-r--r--src/view/com/util/PostMeta.tsx10
-rw-r--r--src/view/com/util/UserAvatar.tsx7
-rw-r--r--src/view/com/util/UserBanner.tsx21
-rw-r--r--src/view/com/util/ViewHeader.tsx2
-rw-r--r--src/view/com/util/ViewSelector.tsx63
-rw-r--r--src/view/com/util/forms/Button.tsx5
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx27
-rw-r--r--src/view/com/util/forms/RadioButton.tsx4
-rw-r--r--src/view/com/util/forms/RadioGroup.tsx3
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx24
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx (renamed from src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx)15
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx (renamed from src/view/com/util/PostEmbeds/QuoteEmbed.tsx)30
-rw-r--r--src/view/com/util/post-embeds/YoutubeEmbed.tsx55
-rw-r--r--src/view/com/util/post-embeds/index.tsx (renamed from src/view/com/util/PostEmbeds/index.tsx)61
-rw-r--r--src/view/com/util/text/RichText.tsx106
18 files changed, 315 insertions, 288 deletions
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index f356f0b09..703869be1 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -29,6 +29,7 @@ type Event =
   | GestureResponderEvent
 
 export const Link = observer(function Link({
+  testID,
   style,
   href,
   title,
@@ -36,6 +37,7 @@ export const Link = observer(function Link({
   noFeedback,
   asAnchor,
 }: {
+  testID?: string
   style?: StyleProp<ViewStyle>
   href?: string
   title?: string
@@ -58,6 +60,7 @@ export const Link = observer(function Link({
   if (noFeedback) {
     return (
       <TouchableWithoutFeedback
+        testID={testID}
         onPress={onPress}
         // @ts-ignore web only -prf
         href={asAnchor ? href : undefined}>
@@ -69,6 +72,7 @@ export const Link = observer(function Link({
   }
   return (
     <TouchableOpacity
+      testID={testID}
       style={style}
       onPress={onPress}
       // @ts-ignore web only -prf
@@ -79,6 +83,7 @@ export const Link = observer(function Link({
 })
 
 export const TextLink = observer(function TextLink({
+  testID,
   type = 'md',
   style,
   href,
@@ -86,6 +91,7 @@ export const TextLink = observer(function TextLink({
   numberOfLines,
   lineHeight,
 }: {
+  testID?: string
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
@@ -106,6 +112,7 @@ export const TextLink = observer(function TextLink({
 
   return (
     <Text
+      testID={testID}
       type={type}
       style={style}
       numberOfLines={numberOfLines}
@@ -120,6 +127,7 @@ export const TextLink = observer(function TextLink({
  * Only acts as a link on desktop web
  */
 export const DesktopWebTextLink = observer(function DesktopWebTextLink({
+  testID,
   type = 'md',
   style,
   href,
@@ -127,6 +135,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   numberOfLines,
   lineHeight,
 }: {
+  testID?: string
   type?: TypographyVariant
   style?: StyleProp<TextStyle>
   href: string
@@ -137,6 +146,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   if (isDesktopWeb) {
     return (
       <TextLink
+        testID={testID}
         type={type}
         style={style}
         href={href}
@@ -148,6 +158,7 @@ export const DesktopWebTextLink = observer(function DesktopWebTextLink({
   }
   return (
     <Text
+      testID={testID}
       type={type}
       style={style}
       numberOfLines={numberOfLines}
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index 00e35eef7..6904928f4 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -45,12 +45,12 @@ interface PostCtrlsOpts {
   style?: StyleProp<ViewStyle>
   replyCount?: number
   repostCount?: number
-  upvoteCount?: number
+  likeCount?: number
   isReposted: boolean
-  isUpvoted: boolean
+  isLiked: boolean
   onPressReply: () => void
   onPressToggleRepost: () => Promise<void>
-  onPressToggleUpvote: () => Promise<void>
+  onPressToggleLike: () => Promise<void>
   onCopyPostText: () => void
   onOpenTranslate: () => void
   onDeletePost: () => void
@@ -157,26 +157,26 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     })
   }
 
-  const onPressToggleUpvoteWrapper = () => {
-    if (!opts.isUpvoted) {
+  const onPressToggleLikeWrapper = () => {
+    if (!opts.isLiked) {
       ReactNativeHapticFeedback.trigger('impactMedium')
       setLikeMod(1)
       opts
-        .onPressToggleUpvote()
+        .onPressToggleLike()
         .catch(_e => undefined)
         .then(() => setLikeMod(0))
       // DISABLED see #135
       // likeRef.current?.trigger(
       //   {start: ctrlAnimStart, style: ctrlAnimStyle},
       //   async () => {
-      //     await opts.onPressToggleUpvote().catch(_e => undefined)
+      //     await opts.onPressToggleLike().catch(_e => undefined)
       //     setLikeMod(0)
       //   },
       // )
     } else {
       setLikeMod(-1)
       opts
-        .onPressToggleUpvote()
+        .onPressToggleLike()
         .catch(_e => undefined)
         .then(() => setLikeMod(0))
     }
@@ -186,6 +186,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     <View style={[styles.ctrls, opts.style]}>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="replyBtn"
           style={styles.ctrl}
           hitSlop={HITSLOP}
           onPress={opts.onPressReply}>
@@ -203,6 +204,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       </View>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="repostBtn"
           hitSlop={HITSLOP}
           onPress={onPressToggleRepostWrapper}
           style={styles.ctrl}>
@@ -230,6 +232,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           }
           {typeof opts.repostCount !== 'undefined' ? (
             <Text
+              testID="repostCount"
               style={
                 opts.isReposted || repostMod > 0
                   ? [s.bold, s.green3, s.f15, s.ml5]
@@ -242,12 +245,13 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       </View>
       <View style={s.flex1}>
         <TouchableOpacity
+          testID="likeBtn"
           style={styles.ctrl}
           hitSlop={HITSLOP}
-          onPress={onPressToggleUpvoteWrapper}>
-          {opts.isUpvoted || likeMod > 0 ? (
+          onPress={onPressToggleLikeWrapper}>
+          {opts.isLiked || likeMod > 0 ? (
             <HeartIconSolid
-              style={styles.ctrlIconUpvoted as StyleProp<ViewStyle>}
+              style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
               size={opts.big ? 22 : 16}
             />
           ) : (
@@ -259,9 +263,9 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           )}
           {
             undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
-            {opts.isUpvoted || likeMod > 0 ? (
+            {opts.isLiked || likeMod > 0 ? (
               <HeartIconSolid
-                style={styles.ctrlIconUpvoted as ViewStyle}
+                style={styles.ctrlIconLiked as ViewStyle}
                 size={opts.big ? 22 : 16}
               />
             ) : (
@@ -276,14 +280,15 @@ export function PostCtrls(opts: PostCtrlsOpts) {
             )}
             </TriggerableAnimated>*/
           }
-          {typeof opts.upvoteCount !== 'undefined' ? (
+          {typeof opts.likeCount !== 'undefined' ? (
             <Text
+              testID="likeCount"
               style={
-                opts.isUpvoted || likeMod > 0
+                opts.isLiked || likeMod > 0
                   ? [s.bold, s.red3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.upvoteCount + likeMod}
+              {opts.likeCount + likeMod}
             </Text>
           ) : undefined}
         </TouchableOpacity>
@@ -291,6 +296,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       <View style={s.flex1}>
         {opts.big ? undefined : (
           <PostDropdownBtn
+            testID="postDropdownBtn"
             style={styles.ctrl}
             itemUri={opts.itemUri}
             itemCid={opts.itemCid}
@@ -330,7 +336,7 @@ const styles = StyleSheet.create({
   ctrlIconReposted: {
     color: colors.green3,
   },
-  ctrlIconUpvoted: {
+  ctrlIconLiked: {
     color: colors.red3,
   },
   mt1: {
diff --git a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx b/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
deleted file mode 100644
index d9425fe4e..000000000
--- a/src/view/com/util/PostEmbeds/YoutubeEmbed.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-import React, {useEffect} from 'react'
-import {useState} from 'react'
-import {
-  View,
-  StyleSheet,
-  Pressable,
-  TouchableWithoutFeedback,
-  EmitterSubscription,
-} from 'react-native'
-import YoutubePlayer from 'react-native-youtube-iframe'
-import {usePalette} from 'lib/hooks/usePalette'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import ExternalLinkEmbed from './ExternalLinkEmbed'
-import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
-import {useStores} from 'state/index'
-
-const YoutubeEmbed = ({
-  link,
-  videoId,
-}: {
-  videoId: string
-  link: PresentedExternal
-}) => {
-  const store = useStores()
-  const [displayVideoPlayer, setDisplayVideoPlayer] = useState(false)
-  const [playerDimensions, setPlayerDimensions] = useState({
-    width: 0,
-    height: 0,
-  })
-  const pal = usePalette('default')
-  const handlePlayButtonPressed = () => {
-    setDisplayVideoPlayer(true)
-  }
-  const handleOnLayout = (event: {
-    nativeEvent: {layout: {width: any; height: any}}
-  }) => {
-    setPlayerDimensions({
-      width: event.nativeEvent.layout.width,
-      height: event.nativeEvent.layout.height,
-    })
-  }
-  useEffect(() => {
-    let sub: EmitterSubscription
-    if (displayVideoPlayer) {
-      sub = store.onNavigation(() => {
-        setDisplayVideoPlayer(false)
-      })
-    }
-    return () => sub && sub.remove()
-  }, [displayVideoPlayer, store])
-
-  const imageChild = (
-    <Pressable onPress={handlePlayButtonPressed} style={styles.playButton}>
-      <FontAwesomeIcon icon="play" size={24} color="white" />
-    </Pressable>
-  )
-
-  if (!displayVideoPlayer) {
-    return (
-      <View
-        style={[styles.extOuter, pal.view, pal.border]}
-        onLayout={handleOnLayout}>
-        <ExternalLinkEmbed
-          link={link}
-          onImagePress={handlePlayButtonPressed}
-          imageChild={imageChild}
-        />
-      </View>
-    )
-  }
-
-  const height = (playerDimensions.width / 16) * 9
-  const noop = () => {}
-
-  return (
-    <TouchableWithoutFeedback onPress={noop}>
-      <View>
-        {/* Removing the outter View will make tap events propagate to parents */}
-        <YoutubePlayer
-          initialPlayerParams={{
-            modestbranding: true,
-          }}
-          webViewProps={{
-            startInLoadingState: true,
-          }}
-          height={height}
-          videoId={videoId}
-          webViewStyle={styles.webView}
-        />
-      </View>
-    </TouchableWithoutFeedback>
-  )
-}
-
-const styles = StyleSheet.create({
-  extOuter: {
-    borderWidth: 1,
-    borderRadius: 8,
-    marginTop: 4,
-  },
-  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',
-  },
-})
-
-export default YoutubeEmbed
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index c53de5c1f..a675283b8 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -16,7 +16,6 @@ interface PostMetaOpts {
   postHref: string
   timestamp: string
   did?: string
-  declarationCid?: string
   showFollowBtn?: boolean
 }
 
@@ -34,13 +33,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     setDidFollow(true)
   }, [setDidFollow])
 
-  if (
-    opts.showFollowBtn &&
-    !isMe &&
-    (!isFollowing || didFollow) &&
-    opts.did &&
-    opts.declarationCid
-  ) {
+  if (opts.showFollowBtn && !isMe && (!isFollowing || didFollow) && opts.did) {
     // two-liner with follow button
     return (
       <View style={styles.metaTwoLine}>
@@ -79,7 +72,6 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
           <FollowButton
             type="default"
             did={opts.did}
-            declarationCid={opts.declarationCid}
             onToggleFollow={onToggleFollow}
           />
         </View>
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 2e0632521..ff741cd34 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -23,6 +23,7 @@ import {isWeb} from 'platform/detection'
 function DefaultAvatar({size}: {size: number}) {
   return (
     <Svg
+      testID="userAvatarFallback"
       width={size}
       height={size}
       viewBox="0 0 24 24"
@@ -56,6 +57,7 @@ export function UserAvatar({
 
   const dropdownItems = [
     !isWeb && {
+      testID: 'changeAvatarCameraBtn',
       label: 'Camera',
       icon: 'camera' as IconProp,
       onPress: async () => {
@@ -73,6 +75,7 @@ export function UserAvatar({
       },
     },
     {
+      testID: 'changeAvatarLibraryBtn',
       label: 'Library',
       icon: 'image' as IconProp,
       onPress: async () => {
@@ -94,6 +97,7 @@ export function UserAvatar({
       },
     },
     {
+      testID: 'changeAvatarRemoveBtn',
       label: 'Remove',
       icon: ['far', 'trash-can'] as IconProp,
       onPress: async () => {
@@ -104,6 +108,7 @@ export function UserAvatar({
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
     <DropdownButton
+      testID="changeAvatarBtn"
       type="bare"
       items={dropdownItems}
       openToRight
@@ -112,6 +117,7 @@ export function UserAvatar({
       menuWidth={170}>
       {avatar ? (
         <HighPriorityImage
+          testID="userAvatarImage"
           style={{
             width: size,
             height: size,
@@ -132,6 +138,7 @@ export function UserAvatar({
     </DropdownButton>
   ) : avatar ? (
     <HighPriorityImage
+      testID="userAvatarImage"
       style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
       resizeMode="stretch"
       source={{uri: avatar}}
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 8317f93ac..56d7e370a 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -33,6 +33,7 @@ export function UserBanner({
 
   const dropdownItems = [
     !isWeb && {
+      testID: 'changeBannerCameraBtn',
       label: 'Camera',
       icon: 'camera' as IconProp,
       onPress: async () => {
@@ -51,6 +52,7 @@ export function UserBanner({
       },
     },
     {
+      testID: 'changeBannerLibraryBtn',
       label: 'Library',
       icon: 'image' as IconProp,
       onPress: async () => {
@@ -73,6 +75,7 @@ export function UserBanner({
       },
     },
     {
+      testID: 'changeBannerRemoveBtn',
       label: 'Remove',
       icon: ['far', 'trash-can'] as IconProp,
       onPress: () => {
@@ -84,6 +87,7 @@ export function UserBanner({
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
     <DropdownButton
+      testID="changeBannerBtn"
       type="bare"
       items={dropdownItems}
       openToRight
@@ -91,9 +95,16 @@ export function UserBanner({
       bottomOffset={-10}
       menuWidth={170}>
       {banner ? (
-        <Image style={styles.bannerImage} source={{uri: banner}} />
+        <Image
+          testID="userBannerImage"
+          style={styles.bannerImage}
+          source={{uri: banner}}
+        />
       ) : (
-        <View style={[styles.bannerImage, styles.defaultBanner]} />
+        <View
+          testID="userBannerFallback"
+          style={[styles.bannerImage, styles.defaultBanner]}
+        />
       )}
       <View style={[styles.editButtonContainer, pal.btn]}>
         <FontAwesomeIcon
@@ -106,12 +117,16 @@ export function UserBanner({
     </DropdownButton>
   ) : banner ? (
     <Image
+      testID="userBannerImage"
       style={styles.bannerImage}
       resizeMode="cover"
       source={{uri: banner}}
     />
   ) : (
-    <View style={[styles.bannerImage, styles.defaultBanner]} />
+    <View
+      testID="userBannerFallback"
+      style={[styles.bannerImage, styles.defaultBanner]}
+    />
   )
 }
 
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index a99282512..ad0a5a1d2 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -51,7 +51,7 @@ export const ViewHeader = observer(function ({
     return (
       <Container hideOnScroll={hideOnScroll || false}>
         <TouchableOpacity
-          testID="viewHeaderBackOrMenuBtn"
+          testID="viewHeaderDrawerBtn"
           onPress={canGoBack ? onPressBack : onPressMenu}
           hitSlop={BACK_HITSLOP}
           style={canGoBack ? styles.backBtn : styles.backBtnWide}>
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index e1280fd82..82351cf08 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -47,13 +47,18 @@ export function ViewSelector({
   // events
   // =
 
-  const onSwipeEnd = (dx: number) => {
-    if (dx !== 0) {
-      setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length))
-    }
-  }
-  const onPressSelection = (index: number) =>
-    setSelectedIndex(clamp(index, 0, sections.length))
+  const onSwipeEnd = React.useCallback(
+    (dx: number) => {
+      if (dx !== 0) {
+        setSelectedIndex(clamp(selectedIndex + dx, 0, sections.length))
+      }
+    },
+    [setSelectedIndex, selectedIndex, sections],
+  )
+  const onPressSelection = React.useCallback(
+    (index: number) => setSelectedIndex(clamp(index, 0, sections.length)),
+    [setSelectedIndex, sections],
+  )
   useEffect(() => {
     onSelectView?.(selectedIndex)
   }, [selectedIndex, onSelectView])
@@ -61,27 +66,33 @@ export function ViewSelector({
   // rendering
   // =
 
-  const renderItemInternal = ({item}: {item: any}) => {
-    if (item === HEADER_ITEM) {
-      if (renderHeader) {
-        return renderHeader()
+  const renderItemInternal = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === HEADER_ITEM) {
+        if (renderHeader) {
+          return renderHeader()
+        }
+        return <View />
+      } else if (item === SELECTOR_ITEM) {
+        return (
+          <Selector
+            items={sections}
+            panX={panX}
+            selectedIndex={selectedIndex}
+            onSelect={onPressSelection}
+          />
+        )
+      } else {
+        return renderItem(item)
       }
-      return <View />
-    } else if (item === SELECTOR_ITEM) {
-      return (
-        <Selector
-          items={sections}
-          panX={panX}
-          selectedIndex={selectedIndex}
-          onSelect={onPressSelection}
-        />
-      )
-    } else {
-      return renderItem(item)
-    }
-  }
+    },
+    [sections, panX, selectedIndex, onPressSelection, renderHeader, renderItem],
+  )
 
-  const data = [HEADER_ITEM, SELECTOR_ITEM, ...items]
+  const data = React.useMemo(
+    () => [HEADER_ITEM, SELECTOR_ITEM, ...items],
+    [items],
+  )
   return (
     <HorzSwipe
       hasPriority
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index f3f4d1c79..b7c058d2d 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -27,11 +27,13 @@ export function Button({
   style,
   onPress,
   children,
+  testID,
 }: React.PropsWithChildren<{
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
   onPress?: () => void
+  testID?: string
 }>) {
   const theme = useTheme()
   const outerStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>(type, {
@@ -107,7 +109,8 @@ export function Button({
   return (
     <TouchableOpacity
       style={[outerStyle, styles.outer, style]}
-      onPress={onPress}>
+      onPress={onPress}
+      testID={testID}>
       {label ? (
         <Text type="button" style={[labelStyle]}>
           {label}
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index d6ae800c6..938c346cd 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -24,6 +24,7 @@ const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
 const ESTIMATED_MENU_ITEM_HEIGHT = 52
 
 export interface DropdownItem {
+  testID?: string
   icon?: IconProp
   label: string
   onPress: () => void
@@ -33,6 +34,7 @@ type MaybeDropdownItem = DropdownItem | false | undefined
 export type DropdownButtonType = ButtonType | 'bare'
 
 export function DropdownButton({
+  testID,
   type = 'bare',
   style,
   items,
@@ -43,6 +45,7 @@ export function DropdownButton({
   rightOffset = 0,
   bottomOffset = 0,
 }: {
+  testID?: string
   type?: DropdownButtonType
   style?: StyleProp<ViewStyle>
   items: MaybeDropdownItem[]
@@ -90,22 +93,18 @@ export function DropdownButton({
   if (type === 'bare') {
     return (
       <TouchableOpacity
+        testID={testID}
         style={style}
         onPress={onPress}
         hitSlop={HITSLOP}
-        // Fix an issue where specific references cause runtime error in jest environment
-        ref={
-          typeof process !== 'undefined' && process.env.JEST_WORKER_ID != null
-            ? null
-            : ref
-        }>
+        ref={ref}>
         {children}
       </TouchableOpacity>
     )
   }
   return (
     <View ref={ref}>
-      <Button onPress={onPress} style={style} label={label}>
+      <Button testID={testID} onPress={onPress} style={style} label={label}>
         {children}
       </Button>
     </View>
@@ -113,6 +112,7 @@ export function DropdownButton({
 }
 
 export function PostDropdownBtn({
+  testID,
   style,
   children,
   itemUri,
@@ -123,6 +123,7 @@ export function PostDropdownBtn({
   onOpenTranslate,
   onDeletePost,
 }: {
+  testID?: string
   style?: StyleProp<ViewStyle>
   children?: React.ReactNode
   itemUri: string
@@ -138,6 +139,7 @@ export function PostDropdownBtn({
 
   const dropdownItems: DropdownItem[] = [
     {
+      testID: 'postDropdownTranslateBtn',
       icon: 'language',
       label: 'Translate...',
       onPress() {
@@ -145,6 +147,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownCopyTextBtn',
       icon: ['far', 'paste'],
       label: 'Copy post text',
       onPress() {
@@ -152,6 +155,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownShareBtn',
       icon: 'share',
       label: 'Share...',
       onPress() {
@@ -159,6 +163,7 @@ export function PostDropdownBtn({
       },
     },
     {
+      testID: 'postDropdownReportBtn',
       icon: 'circle-exclamation',
       label: 'Report post',
       onPress() {
@@ -171,6 +176,7 @@ export function PostDropdownBtn({
     },
     isAuthor
       ? {
+          testID: 'postDropdownDeleteBtn',
           icon: ['far', 'trash-can'],
           label: 'Delete post',
           onPress() {
@@ -186,7 +192,11 @@ export function PostDropdownBtn({
   ].filter(Boolean) as DropdownItem[]
 
   return (
-    <DropdownButton style={style} items={dropdownItems} menuWidth={200}>
+    <DropdownButton
+      testID={testID}
+      style={style}
+      items={dropdownItems}
+      menuWidth={200}>
       {children}
     </DropdownButton>
   )
@@ -291,6 +301,7 @@ const DropdownItems = ({
         ]}>
         {items.map((item, index) => (
           <TouchableOpacity
+            testID={item.testID}
             key={index}
             style={[styles.menuItem]}
             onPress={() => onPressItem(index)}>
diff --git a/src/view/com/util/forms/RadioButton.tsx b/src/view/com/util/forms/RadioButton.tsx
index d6b2bb119..f5696a76d 100644
--- a/src/view/com/util/forms/RadioButton.tsx
+++ b/src/view/com/util/forms/RadioButton.tsx
@@ -6,12 +6,14 @@ import {useTheme} from 'lib/ThemeContext'
 import {choose} from 'lib/functions'
 
 export function RadioButton({
+  testID,
   type = 'default-light',
   label,
   isSelected,
   style,
   onPress,
 }: {
+  testID?: string
   type?: ButtonType
   label: string
   isSelected: boolean
@@ -119,7 +121,7 @@ export function RadioButton({
     },
   })
   return (
-    <Button type={type} onPress={onPress} style={style}>
+    <Button testID={testID} type={type} onPress={onPress} style={style}>
       <View style={styles.outer}>
         <View style={[circleStyle, styles.circle]}>
           {isSelected ? (
diff --git a/src/view/com/util/forms/RadioGroup.tsx b/src/view/com/util/forms/RadioGroup.tsx
index 901b0cdd8..071540b73 100644
--- a/src/view/com/util/forms/RadioGroup.tsx
+++ b/src/view/com/util/forms/RadioGroup.tsx
@@ -10,11 +10,13 @@ export interface RadioGroupItem {
 }
 
 export function RadioGroup({
+  testID,
   type,
   items,
   initialSelection = '',
   onSelect,
 }: {
+  testID?: string
   type?: ButtonType
   items: RadioGroupItem[]
   initialSelection?: string
@@ -30,6 +32,7 @@ export function RadioGroup({
       {items.map((item, i) => (
         <RadioButton
           key={item.key}
+          testID={testID ? `${testID}-${item.key}` : undefined}
           style={i !== 0 ? s.mt2 : undefined}
           type={type}
           label={item.label}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 24dbe6a52..ddb09ce39 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -4,9 +4,9 @@ import {
   StyleProp,
   StyleSheet,
   TouchableOpacity,
+  View,
   ViewStyle,
 } from 'react-native'
-// import Image from 'view/com/util/images/Image'
 import {clamp} from 'lib/numbers'
 import {useStores} from 'state/index'
 import {Dim} from 'lib/media/manip'
@@ -51,16 +51,24 @@ export function AutoSizedImage({
     })
   }, [dim, setDim, setAspectRatio, store, uri])
 
+  if (onPress || onLongPress || onPressIn) {
+    return (
+      <TouchableOpacity
+        onPress={onPress}
+        onLongPress={onLongPress}
+        onPressIn={onPressIn}
+        delayPressIn={DELAY_PRESS_IN}
+        style={[styles.container, style]}>
+        <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
+        {children}
+      </TouchableOpacity>
+    )
+  }
   return (
-    <TouchableOpacity
-      onPress={onPress}
-      onLongPress={onLongPress}
-      onPressIn={onPressIn}
-      delayPressIn={DELAY_PRESS_IN}
-      style={[styles.container, style]}>
+    <View style={[styles.container, style]}>
       <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
       {children}
-    </TouchableOpacity>
+    </View>
   )
 }
 
diff --git a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index e8c63bdb7..a4cbb3e29 100644
--- a/src/view/com/util/PostEmbeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -3,25 +3,20 @@ import {Text} from '../text/Text'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {StyleSheet, View} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
-import {PresentedExternal} from '@atproto/api/dist/client/types/app/bsky/embed/external'
+import {AppBskyEmbedExternal} from '@atproto/api'
 
-const ExternalLinkEmbed = ({
+export const ExternalLinkEmbed = ({
   link,
-  onImagePress,
   imageChild,
 }: {
-  link: PresentedExternal
-  onImagePress?: () => void
+  link: AppBskyEmbedExternal.ViewExternal
   imageChild?: React.ReactNode
 }) => {
   const pal = usePalette('default')
   return (
     <>
       {link.thumb ? (
-        <AutoSizedImage
-          uri={link.thumb}
-          style={styles.extImage}
-          onPress={onImagePress}>
+        <AutoSizedImage uri={link.thumb} style={styles.extImage}>
           {imageChild}
         </AutoSizedImage>
       ) : undefined}
@@ -65,5 +60,3 @@ const styles = StyleSheet.create({
     marginTop: 4,
   },
 })
-
-export default ExternalLinkEmbed
diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index fee67c9bc..9dc5739a0 100644
--- a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,13 +1,21 @@
-import {StyleSheet} from 'react-native'
 import React from 'react'
+import {StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api'
 import {AtUri} from '../../../../third-party/uri'
 import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/models/ui/shell'
+import {PostEmbeds} from '.'
 
-const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
+export function QuoteEmbed({
+  quote,
+  style,
+}: {
+  quote: ComposerOptsQuote
+  style?: StyleProp<ViewStyle>
+}) {
   const pal = usePalette('default')
   const itemUrip = new AtUri(quote.uri)
   const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}`
@@ -16,9 +24,18 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
     () => quote.text.trim().length === 0,
     [quote.text],
   )
+  const imagesEmbed = React.useMemo(
+    () =>
+      quote.embeds?.find(
+        embed =>
+          AppBskyEmbedImages.isView(embed) ||
+          AppBskyEmbedRecordWithMedia.isView(embed),
+      ),
+    [quote.embeds],
+  )
   return (
     <Link
-      style={[styles.container, pal.border]}
+      style={[styles.container, pal.border, style]}
       href={itemHref}
       title={itemTitle}>
       <PostMeta
@@ -37,6 +54,12 @@ const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
           quote.text
         )}
       </Text>
+      {AppBskyEmbedImages.isView(imagesEmbed) && (
+        <PostEmbeds embed={imagesEmbed} />
+      )}
+      {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
+        <PostEmbeds embed={imagesEmbed.media} />
+      )}
     </Link>
   )
 }
@@ -48,7 +71,6 @@ const styles = StyleSheet.create({
     borderRadius: 8,
     paddingVertical: 8,
     paddingHorizontal: 12,
-    marginVertical: 8,
     borderWidth: 1,
   },
   quotePost: {
diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
new file mode 100644
index 000000000..2ca0750a3
--- /dev/null
+++ b/src/view/com/util/post-embeds/YoutubeEmbed.tsx
@@ -0,0 +1,55 @@
+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
+      style={[styles.extOuter, pal.view, pal.border, style]}
+      href={link.uri}
+      noFeedback>
+      <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/PostEmbeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 02a8aa90e..726bea6e7 100644
--- a/src/view/com/util/PostEmbeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -10,6 +10,7 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
   AppBskyFeedPost,
 } from '@atproto/api'
 import {Link} from '../Link'
@@ -19,15 +20,16 @@ import {ImagesLightbox} from 'state/models/ui/shell'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {saveImageModal} from 'lib/media/manip'
-import YoutubeEmbed from './YoutubeEmbed'
-import ExternalLinkEmbed from './ExternalLinkEmbed'
+import {YoutubeEmbed} from './YoutubeEmbed'
+import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import QuoteEmbed from './QuoteEmbed'
 
 type Embed =
-  | AppBskyEmbedRecord.Presented
-  | AppBskyEmbedImages.Presented
-  | AppBskyEmbedExternal.Presented
+  | AppBskyEmbedRecord.View
+  | AppBskyEmbedImages.View
+  | AppBskyEmbedExternal.View
+  | AppBskyEmbedRecordWithMedia.View
   | {$type: string; [k: string]: unknown}
 
 export function PostEmbeds({
@@ -39,11 +41,35 @@ export function PostEmbeds({
 }) {
   const pal = usePalette('default')
   const store = useStores()
-  if (AppBskyEmbedRecord.isPresented(embed)) {
+
+  if (
+    AppBskyEmbedRecordWithMedia.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+    AppBskyFeedPost.isRecord(embed.record.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.record.value).success
+  ) {
+    return (
+      <View style={[styles.stackContainer, style]}>
+        <PostEmbeds embed={embed.media} />
+        <QuoteEmbed
+          quote={{
+            author: embed.record.record.author,
+            cid: embed.record.record.cid,
+            uri: embed.record.record.uri,
+            indexedAt: embed.record.record.indexedAt,
+            text: embed.record.record.value.text,
+            embeds: embed.record.record.embeds,
+          }}
+        />
+      </View>
+    )
+  }
+
+  if (AppBskyEmbedRecord.isView(embed)) {
     if (
-      AppBskyEmbedRecord.isPresentedRecord(embed.record) &&
-      AppBskyFeedPost.isRecord(embed.record.record) &&
-      AppBskyFeedPost.validateRecord(embed.record.record).success
+      AppBskyEmbedRecord.isViewRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.value) &&
+      AppBskyFeedPost.validateRecord(embed.record.value).success
     ) {
       return (
         <QuoteEmbed
@@ -51,14 +77,17 @@ export function PostEmbeds({
             author: embed.record.author,
             cid: embed.record.cid,
             uri: embed.record.uri,
-            indexedAt: embed.record.record.createdAt, // TODO
-            text: embed.record.record.text,
+            indexedAt: embed.record.indexedAt,
+            text: embed.record.value.text,
+            embeds: embed.record.embeds,
           }}
+          style={style}
         />
       )
     }
   }
-  if (AppBskyEmbedImages.isPresented(embed)) {
+
+  if (AppBskyEmbedImages.isView(embed)) {
     if (embed.images.length > 0) {
       const uris = embed.images.map(img => img.fullsize)
       const openLightbox = (index: number) => {
@@ -129,12 +158,13 @@ export function PostEmbeds({
       }
     }
   }
-  if (AppBskyEmbedExternal.isPresented(embed)) {
+
+  if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
     const youtubeVideoId = getYoutubeVideoId(link.uri)
 
     if (youtubeVideoId) {
-      return <YoutubeEmbed videoId={youtubeVideoId} link={link} />
+      return <YoutubeEmbed link={link} style={style} />
     }
 
     return (
@@ -150,6 +180,9 @@ export function PostEmbeds({
 }
 
 const styles = StyleSheet.create({
+  stackContainer: {
+    gap: 6,
+  },
   imagesContainer: {
     marginTop: 4,
   },
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index d4cf19172..804db002a 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -1,20 +1,22 @@
 import React from 'react'
 import {TextStyle, StyleProp} from 'react-native'
+import {RichText as RichTextObj, AppBskyRichtextFacet} from '@atproto/api'
 import {TextLink} from '../Link'
 import {Text} from './Text'
 import {lh} from 'lib/styles'
 import {toShortUrl} from 'lib/strings/url-helpers'
-import {RichText as RichTextObj, Entity} from 'lib/strings/rich-text'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 
 export function RichText({
+  testID,
   type = 'md',
   richText,
   lineHeight = 1.2,
   style,
   numberOfLines,
 }: {
+  testID?: string
   type?: TypographyVariant
   richText?: RichTextObj
   lineHeight?: number
@@ -29,17 +31,24 @@ export function RichText({
     return null
   }
 
-  const {text, entities} = richText
-  if (!entities?.length) {
+  const {text, facets} = richText
+  if (!facets?.length) {
     if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
       style = {
         fontSize: 26,
         lineHeight: 30,
       }
-      return <Text style={[style, pal.text]}>{text}</Text>
+      return (
+        <Text testID={testID} style={[style, pal.text]}>
+          {text}
+        </Text>
+      )
     }
     return (
-      <Text type={type} style={[style, pal.text, lineHeightStyle]}>
+      <Text
+        testID={testID}
+        type={type}
+        style={[style, pal.text, lineHeightStyle]}>
         {text}
       </Text>
     )
@@ -49,40 +58,40 @@ export function RichText({
   } else if (!Array.isArray(style)) {
     style = [style]
   }
-  entities.sort(sortByIndex)
-  const segments = Array.from(toSegments(text, entities))
+
   const els = []
   let key = 0
-  for (const segment of segments) {
-    if (typeof segment === 'string') {
-      els.push(segment)
+  for (const segment of richText.segments()) {
+    const link = segment.link
+    const mention = segment.mention
+    if (mention && AppBskyRichtextFacet.validateMention(mention).success) {
+      els.push(
+        <TextLink
+          key={key}
+          type={type}
+          text={segment.text}
+          href={`/profile/${mention.did}`}
+          style={[style, lineHeightStyle, pal.link]}
+        />,
+      )
+    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
+      els.push(
+        <TextLink
+          key={key}
+          type={type}
+          text={toShortUrl(segment.text)}
+          href={link.uri}
+          style={[style, lineHeightStyle, pal.link]}
+        />,
+      )
     } else {
-      if (segment.entity.type === 'mention') {
-        els.push(
-          <TextLink
-            key={key}
-            type={type}
-            text={segment.text}
-            href={`/profile/${segment.entity.value}`}
-            style={[style, lineHeightStyle, pal.link]}
-          />,
-        )
-      } else if (segment.entity.type === 'link') {
-        els.push(
-          <TextLink
-            key={key}
-            type={type}
-            text={toShortUrl(segment.text)}
-            href={segment.entity.value}
-            style={[style, lineHeightStyle, pal.link]}
-          />,
-        )
-      }
+      els.push(segment.text)
     }
     key++
   }
   return (
     <Text
+      testID={testID}
       type={type}
       style={[style, pal.text, lineHeightStyle]}
       numberOfLines={numberOfLines}>
@@ -90,38 +99,3 @@ export function RichText({
     </Text>
   )
 }
-
-function sortByIndex(a: Entity, b: Entity) {
-  return a.index.start - b.index.start
-}
-
-function* toSegments(text: string, entities: Entity[]) {
-  let cursor = 0
-  let i = 0
-  do {
-    let currEnt = entities[i]
-    if (cursor < currEnt.index.start) {
-      yield text.slice(cursor, currEnt.index.start)
-    } else if (cursor > currEnt.index.start) {
-      i++
-      continue
-    }
-    if (currEnt.index.start < currEnt.index.end) {
-      let subtext = text.slice(currEnt.index.start, currEnt.index.end)
-      if (!subtext.trim()) {
-        // dont yield links to empty strings
-        yield subtext
-      } else {
-        yield {
-          entity: currEnt,
-          text: subtext,
-        }
-      }
-    }
-    cursor = currEnt.index.end
-    i++
-  } while (i < entities.length)
-  if (cursor < text.length) {
-    yield text.slice(cursor, text.length)
-  }
-}