about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-10-07 11:15:17 -0700
committerGitHub <noreply@github.com>2024-10-07 11:15:17 -0700
commit58b1d9326d7f5f308746e2471f5a7552bb0db250 (patch)
tree5e7a2d671ce4e4a010a1c967711cfd7fa9ec0c7d
parent8d80f1344df4897cfe4f754d37e654809850b794 (diff)
downloadvoidsky-58b1d9326d7f5f308746e2471f5a7552bb0db250.tar.zst
Swipeable to delete chat, custom swipeable (#5614)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r--assets/icons/envelope_open_stroke2_corner0_rounded.svg1
-rw-r--r--src/components/icons/EnveopeOpen.tsx5
-rw-r--r--src/lib/custom-animations/GestureActionView.tsx410
-rw-r--r--src/lib/custom-animations/GestureActionView.web.tsx5
-rw-r--r--src/screens/Messages/components/ChatListItem.tsx380
5 files changed, 634 insertions, 167 deletions
diff --git a/assets/icons/envelope_open_stroke2_corner0_rounded.svg b/assets/icons/envelope_open_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..985476194
--- /dev/null
+++ b/assets/icons/envelope_open_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/icons/EnveopeOpen.tsx b/src/components/icons/EnveopeOpen.tsx
new file mode 100644
index 000000000..2873e8913
--- /dev/null
+++ b/src/components/icons/EnveopeOpen.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Envelope_Open_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z',
+})
diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx
new file mode 100644
index 000000000..79e9db8a9
--- /dev/null
+++ b/src/lib/custom-animations/GestureActionView.tsx
@@ -0,0 +1,410 @@
+import React from 'react'
+import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import Animated, {
+  clamp,
+  interpolate,
+  interpolateColor,
+  runOnJS,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useDerivedValue,
+  useReducedMotion,
+  useSharedValue,
+  withSequence,
+  withTiming,
+} from 'react-native-reanimated'
+
+import {useHaptics} from '#/lib/haptics'
+
+interface GestureAction {
+  color: ColorValue
+  action: () => void
+  threshold: number
+  icon: React.ElementType
+}
+
+interface GestureActions {
+  leftFirst?: GestureAction
+  leftSecond?: GestureAction
+  rightFirst?: GestureAction
+  rightSecond?: GestureAction
+}
+
+const MAX_WIDTH = Dimensions.get('screen').width
+const ICON_SIZE = 32
+
+export function GestureActionView({
+  children,
+  actions,
+}: {
+  children: React.ReactNode
+  actions: GestureActions
+}) {
+  if (
+    (actions.leftSecond && !actions.leftFirst) ||
+    (actions.rightSecond && !actions.rightFirst)
+  ) {
+    throw new Error(
+      'You must provide the first action before the second action',
+    )
+  }
+
+  const [activeAction, setActiveAction] = React.useState<
+    'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
+  >(null)
+
+  const haptic = useHaptics()
+  const isReducedMotion = useReducedMotion()
+
+  const transX = useSharedValue(0)
+  const clampedTransX = useDerivedValue(() => {
+    const min = actions.leftFirst ? -MAX_WIDTH : 0
+    const max = actions.rightFirst ? MAX_WIDTH : 0
+    return clamp(transX.value, min, max)
+  })
+
+  const iconScale = useSharedValue(1)
+  const isActive = useSharedValue(false)
+  const hitFirst = useSharedValue(false)
+  const hitSecond = useSharedValue(false)
+
+  const runPopAnimation = () => {
+    'worklet'
+    if (isReducedMotion) {
+      return
+    }
+
+    iconScale.value = withSequence(
+      withTiming(1.2, {duration: 175}),
+      withTiming(1, {duration: 100}),
+    )
+  }
+
+  useAnimatedReaction(
+    () => transX,
+    () => {
+      if (transX.value === 0) {
+        runOnJS(setActiveAction)(null)
+      } else if (transX.value < 0) {
+        if (
+          actions.leftSecond &&
+          transX.value <= -actions.leftSecond.threshold
+        ) {
+          if (activeAction !== 'leftSecond') {
+            runOnJS(setActiveAction)('leftSecond')
+          }
+        } else if (activeAction !== 'leftFirst') {
+          runOnJS(setActiveAction)('leftFirst')
+        }
+      } else if (transX.value > 0) {
+        if (
+          actions.rightSecond &&
+          transX.value > actions.rightSecond.threshold
+        ) {
+          if (activeAction !== 'rightSecond') {
+            runOnJS(setActiveAction)('rightSecond')
+          }
+        } else if (activeAction !== 'rightFirst') {
+          runOnJS(setActiveAction)('rightFirst')
+        }
+      }
+    },
+  )
+
+  const panGesture = Gesture.Pan()
+    .activeOffsetX([-10, 10])
+    // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
+    // reanimated doesn't offer great support for disabling y/x axes :/
+    .activeOffsetY([-200, 200])
+    .onStart(() => {
+      'worklet'
+      isActive.value = true
+    })
+    .onChange(e => {
+      'worklet'
+      transX.value = e.translationX
+
+      if (e.translationX < 0) {
+        // Left side
+        if (actions.leftSecond) {
+          if (
+            e.translationX <= -actions.leftSecond.threshold &&
+            !hitSecond.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitSecond.value = true
+          } else if (
+            hitSecond.value &&
+            e.translationX > -actions.leftSecond.threshold
+          ) {
+            runPopAnimation()
+            hitSecond.value = false
+          }
+        }
+
+        if (!hitSecond.value && actions.leftFirst) {
+          if (
+            e.translationX <= -actions.leftFirst.threshold &&
+            !hitFirst.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitFirst.value = true
+          } else if (
+            hitFirst.value &&
+            e.translationX > -actions.leftFirst.threshold
+          ) {
+            hitFirst.value = false
+          }
+        }
+      } else if (e.translationX > 0) {
+        // Right side
+        if (actions.rightSecond) {
+          if (
+            e.translationX >= actions.rightSecond.threshold &&
+            !hitSecond.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitSecond.value = true
+          } else if (
+            hitSecond.value &&
+            e.translationX < actions.rightSecond.threshold
+          ) {
+            runPopAnimation()
+            hitSecond.value = false
+          }
+        }
+
+        if (!hitSecond.value && actions.rightFirst) {
+          if (
+            e.translationX >= actions.rightFirst.threshold &&
+            !hitFirst.value
+          ) {
+            runPopAnimation()
+            runOnJS(haptic)()
+            hitFirst.value = true
+          } else if (
+            hitFirst.value &&
+            e.translationX < actions.rightFirst.threshold
+          ) {
+            hitFirst.value = false
+          }
+        }
+      }
+    })
+    .onEnd(e => {
+      'worklet'
+      if (e.translationX < 0) {
+        if (hitSecond.value && actions.leftSecond) {
+          runOnJS(actions.leftSecond.action)()
+        } else if (hitFirst.value && actions.leftFirst) {
+          runOnJS(actions.leftFirst.action)()
+        }
+      } else if (e.translationX > 0) {
+        if (hitSecond.value && actions.rightSecond) {
+          runOnJS(actions.rightSecond.action)()
+        } else if (hitSecond.value && actions.rightFirst) {
+          runOnJS(actions.rightFirst.action)()
+        }
+      }
+      transX.value = withTiming(0, {duration: 200})
+      hitFirst.value = false
+      hitSecond.value = false
+      isActive.value = false
+    })
+
+  const composedGesture = Gesture.Simultaneous(panGesture)
+
+  const animatedSliderStyle = useAnimatedStyle(() => {
+    return {
+      transform: [{translateX: clampedTransX.value}],
+    }
+  })
+
+  const leftSideInterpolation = React.useMemo(() => {
+    return createInterpolation({
+      firstColor: actions.leftFirst?.color,
+      secondColor: actions.leftSecond?.color,
+      firstThreshold: actions.leftFirst?.threshold,
+      secondThreshold: actions.leftSecond?.threshold,
+      side: 'left',
+    })
+  }, [actions.leftFirst, actions.leftSecond])
+
+  const rightSideInterpolation = React.useMemo(() => {
+    return createInterpolation({
+      firstColor: actions.rightFirst?.color,
+      secondColor: actions.rightSecond?.color,
+      firstThreshold: actions.rightFirst?.threshold,
+      secondThreshold: actions.rightSecond?.threshold,
+      side: 'right',
+    })
+  }, [actions.rightFirst, actions.rightSecond])
+
+  const interpolation = React.useMemo<{
+    inputRange: number[]
+    outputRange: ColorValue[]
+  }>(() => {
+    if (!actions.leftFirst) {
+      return rightSideInterpolation!
+    } else if (!actions.rightFirst) {
+      return leftSideInterpolation!
+    } else {
+      return {
+        inputRange: [
+          ...leftSideInterpolation.inputRange,
+          ...rightSideInterpolation.inputRange,
+        ],
+        outputRange: [
+          ...leftSideInterpolation.outputRange,
+          ...rightSideInterpolation.outputRange,
+        ],
+      }
+    }
+  }, [
+    leftSideInterpolation,
+    rightSideInterpolation,
+    actions.leftFirst,
+    actions.rightFirst,
+  ])
+
+  const animatedBackgroundStyle = useAnimatedStyle(() => {
+    return {
+      backgroundColor: interpolateColor(
+        clampedTransX.value,
+        interpolation.inputRange,
+        // @ts-expect-error - Weird type expected by reanimated, but this is okay
+        interpolation.outputRange,
+      ),
+    }
+  })
+
+  const animatedIconStyle = useAnimatedStyle(() => {
+    const absTransX = Math.abs(clampedTransX.value)
+    return {
+      opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
+      transform: [{scale: iconScale.value}],
+    }
+  })
+
+  return (
+    <GestureDetector gesture={composedGesture}>
+      <View>
+        <Animated.View
+          style={[StyleSheet.absoluteFill, animatedBackgroundStyle]}>
+          <View
+            style={{
+              flex: 1,
+              marginHorizontal: 12,
+              justifyContent: 'center',
+              alignItems:
+                activeAction === 'leftFirst' || activeAction === 'leftSecond'
+                  ? 'flex-end'
+                  : 'flex-start',
+            }}>
+            <Animated.View style={[animatedIconStyle]}>
+              {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
+                <actions.leftFirst.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{
+                    color: 'white',
+                  }}
+                />
+              ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
+                <actions.leftSecond.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
+                <actions.rightFirst.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : activeAction === 'rightSecond' &&
+                actions.rightSecond?.icon ? (
+                <actions.rightSecond.icon
+                  height={ICON_SIZE}
+                  width={ICON_SIZE}
+                  style={{color: 'white'}}
+                />
+              ) : null}
+            </Animated.View>
+          </View>
+        </Animated.View>
+        <Animated.View style={animatedSliderStyle}>{children}</Animated.View>
+      </View>
+    </GestureDetector>
+  )
+}
+
+function createInterpolation({
+  firstColor,
+  secondColor,
+  firstThreshold,
+  secondThreshold,
+  side,
+}: {
+  firstColor?: ColorValue
+  secondColor?: ColorValue
+  firstThreshold?: number
+  secondThreshold?: number
+  side: 'left' | 'right'
+}): {
+  inputRange: number[]
+  outputRange: ColorValue[]
+} {
+  if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
+    throw new Error(
+      'You must provide a second color if you provide a second threshold',
+    )
+  }
+
+  if (!firstThreshold) {
+    return {
+      inputRange: [0],
+      outputRange: ['transparent'],
+    }
+  }
+
+  const offset = side === 'left' ? -20 : 20
+
+  if (side === 'left') {
+    firstThreshold = -firstThreshold
+
+    if (secondThreshold) {
+      secondThreshold = -secondThreshold
+    }
+  }
+
+  let res
+  if (secondThreshold) {
+    res = {
+      inputRange: [
+        0,
+        firstThreshold,
+        firstThreshold + offset - 20,
+        secondThreshold,
+      ],
+      outputRange: ['transparent', firstColor!, firstColor!, secondColor!],
+    }
+  } else {
+    res = {
+      inputRange: [0, firstThreshold],
+      outputRange: ['transparent', firstColor!],
+    }
+  }
+
+  if (side === 'left') {
+    // Reverse the input/output ranges
+    res.inputRange.reverse()
+    res.outputRange.reverse()
+  }
+
+  return res
+}
diff --git a/src/lib/custom-animations/GestureActionView.web.tsx b/src/lib/custom-animations/GestureActionView.web.tsx
new file mode 100644
index 000000000..3caaa724f
--- /dev/null
+++ b/src/lib/custom-animations/GestureActionView.web.tsx
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export function GestureActionView({children}: {children: React.ReactNode}) {
+  return children
+}
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 11c071082..bb9c1cd4c 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import React, {useCallback, useMemo, useState} from 'react'
 import {GestureResponderEvent, View} from 'react-native'
 import {
   AppBskyActorDefs,
@@ -10,6 +10,7 @@ import {
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
 import {useHaptics} from '#/lib/haptics'
 import {decrementBadgeCount} from '#/lib/notifications/notifications'
 import {logEvent} from '#/lib/statsig/statsig'
@@ -22,13 +23,18 @@ import {
 import {isNative} from '#/platform/detection'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
 import {useSession} from '#/state/session'
 import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
 import * as tokens from '#/alf/tokens'
+import {useDialogControl} from '#/components/Dialog'
 import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
 import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
+import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
+import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
 import {Link} from '#/components/Link'
 import {useMenuControl} from '#/components/Menu'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -74,15 +80,18 @@ function ChatListItemReady({
   const {_} = useLingui()
   const {currentAccount} = useSession()
   const menuControl = useMenuControl()
+  const leaveConvoControl = useDialogControl()
   const {gtMobile} = useBreakpoints()
   const profile = useProfileShadow(profileUnshadowed)
+  const {mutate: markAsRead} = useMarkAsReadMutation()
   const moderation = React.useMemo(
     () => moderateProfile(profile, moderationOpts),
     [profile, moderationOpts],
   )
   const playHaptic = useHaptics()
+  const isUnread = convo.unreadCount > 0
 
-  const blockInfo = React.useMemo(() => {
+  const blockInfo = useMemo(() => {
     const modui = moderation.ui('profileView')
     const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
     const listBlocks = blocks.filter(alert => alert.source.type === 'list')
@@ -103,7 +112,7 @@ function ChatListItemReady({
 
   const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
 
-  const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
+  const {lastMessage, lastMessageSentAt} = useMemo(() => {
     let lastMessage = _(msg`No messages yet`)
     let lastMessageSentAt: string | null = null
 
@@ -196,183 +205,220 @@ function ChatListItemReady({
     menuControl.open()
   }, [playHaptic, menuControl])
 
+  const markReadAction = {
+    threshold: 120,
+    color: t.palette.primary_500,
+    icon: EnvelopeOpen,
+    action: () => {
+      markAsRead({
+        convoId: convo.id,
+      })
+    },
+  }
+
+  const deleteAction = {
+    threshold: 225,
+    color: t.palette.negative_500,
+    icon: Trash_Stroke2_Corner0_Rounded,
+    action: () => {
+      leaveConvoControl.open()
+    },
+  }
+
+  const actions = isUnread
+    ? {
+        leftFirst: markReadAction,
+        leftSecond: deleteAction,
+      }
+    : {
+        leftFirst: deleteAction,
+      }
+
   return (
-    <View
-      // @ts-expect-error web only
-      onMouseEnter={onMouseEnter}
-      onMouseLeave={onMouseLeave}
-      onFocus={onFocus}
-      onBlur={onMouseLeave}
-      style={[a.relative]}>
+    <GestureActionView actions={actions}>
       <View
-        style={[
-          a.z_10,
-          a.absolute,
-          {top: tokens.space.md, left: tokens.space.lg},
-        ]}>
-        <PreviewableUserAvatar
-          profile={profile}
-          size={52}
-          moderation={moderation.ui('avatar')}
-        />
-      </View>
+        // @ts-expect-error web only
+        onMouseEnter={onMouseEnter}
+        onMouseLeave={onMouseLeave}
+        onFocus={onFocus}
+        onBlur={onMouseLeave}
+        style={[a.relative, t.atoms.bg]}>
+        <View
+          style={[
+            a.z_10,
+            a.absolute,
+            {top: tokens.space.md, left: tokens.space.lg},
+          ]}>
+          <PreviewableUserAvatar
+            profile={profile}
+            size={52}
+            moderation={moderation.ui('avatar')}
+          />
+        </View>
 
-      <Link
-        to={`/messages/${convo.id}`}
-        label={displayName}
-        accessibilityHint={
-          !isDeletedAccount
-            ? _(msg`Go to conversation with ${profile.handle}`)
-            : _(
-                msg`This conversation is with a deleted or a deactivated account. Press for options.`,
-              )
-        }
-        accessibilityActions={
-          isNative
-            ? [
-                {name: 'magicTap', label: _(msg`Open conversation options`)},
-                {name: 'longpress', label: _(msg`Open conversation options`)},
-              ]
-            : undefined
-        }
-        onPress={onPress}
-        onLongPress={isNative ? onLongPress : undefined}
-        onAccessibilityAction={onLongPress}>
-        {({hovered, pressed, focused}) => (
-          <View
-            style={[
-              a.flex_row,
-              isDeletedAccount ? a.align_center : a.align_start,
-              a.flex_1,
-              a.px_lg,
-              a.py_md,
-              a.gap_md,
-              (hovered || pressed || focused) && t.atoms.bg_contrast_25,
-              t.atoms.border_contrast_low,
-            ]}>
-            {/* Avatar goes here */}
-            <View style={{width: 52, height: 52}} />
-
-            <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
-              <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
-                <Text
-                  numberOfLines={1}
-                  style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
+        <Link
+          to={`/messages/${convo.id}`}
+          label={displayName}
+          accessibilityHint={
+            !isDeletedAccount
+              ? _(msg`Go to conversation with ${profile.handle}`)
+              : _(
+                  msg`This conversation is with a deleted or a deactivated account. Press for options.`,
+                )
+          }
+          accessibilityActions={
+            isNative
+              ? [
+                  {name: 'magicTap', label: _(msg`Open conversation options`)},
+                  {name: 'longpress', label: _(msg`Open conversation options`)},
+                ]
+              : undefined
+          }
+          onPress={onPress}
+          onLongPress={isNative ? onLongPress : undefined}
+          onAccessibilityAction={onLongPress}>
+          {({hovered, pressed, focused}) => (
+            <View
+              style={[
+                a.flex_row,
+                isDeletedAccount ? a.align_center : a.align_start,
+                a.flex_1,
+                a.px_lg,
+                a.py_md,
+                a.gap_md,
+                (hovered || pressed || focused) && t.atoms.bg_contrast_25,
+                t.atoms.border_contrast_low,
+              ]}>
+              {/* Avatar goes here */}
+              <View style={{width: 52, height: 52}} />
+
+              <View
+                style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
+                <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
                   <Text
-                    emoji
-                    style={[
-                      a.text_md,
-                      t.atoms.text,
-                      a.font_bold,
-                      {lineHeight: 21},
-                      isDimStyle && t.atoms.text_contrast_medium,
-                    ]}>
-                    {displayName}
+                    numberOfLines={1}
+                    style={[{maxWidth: '85%'}, web([a.leading_normal])]}>
+                    <Text
+                      emoji
+                      style={[
+                        a.text_md,
+                        t.atoms.text,
+                        a.font_bold,
+                        {lineHeight: 21},
+                        isDimStyle && t.atoms.text_contrast_medium,
+                      ]}>
+                      {displayName}
+                    </Text>
                   </Text>
-                </Text>
-                {lastMessageSentAt && (
-                  <TimeElapsed timestamp={lastMessageSentAt}>
-                    {({timeElapsed}) => (
-                      <Text
-                        style={[
-                          a.text_sm,
-                          {lineHeight: 21},
-                          t.atoms.text_contrast_medium,
-                          web({whiteSpace: 'preserve nowrap'}),
-                        ]}>
-                        {' '}
-                        &middot; {timeElapsed}
-                      </Text>
-                    )}
-                  </TimeElapsed>
-                )}
-                {(convo.muted || moderation.blocked) && (
+                  {lastMessageSentAt && (
+                    <TimeElapsed timestamp={lastMessageSentAt}>
+                      {({timeElapsed}) => (
+                        <Text
+                          style={[
+                            a.text_sm,
+                            {lineHeight: 21},
+                            t.atoms.text_contrast_medium,
+                            web({whiteSpace: 'preserve nowrap'}),
+                          ]}>
+                          {' '}
+                          &middot; {timeElapsed}
+                        </Text>
+                      )}
+                    </TimeElapsed>
+                  )}
+                  {(convo.muted || moderation.blocked) && (
+                    <Text
+                      style={[
+                        a.text_sm,
+                        {lineHeight: 21},
+                        t.atoms.text_contrast_medium,
+                        web({whiteSpace: 'preserve nowrap'}),
+                      ]}>
+                      {' '}
+                      &middot;{' '}
+                      <BellStroke
+                        size="xs"
+                        style={[t.atoms.text_contrast_medium]}
+                      />
+                    </Text>
+                  )}
+                </View>
+
+                {!isDeletedAccount && (
                   <Text
-                    style={[
-                      a.text_sm,
-                      {lineHeight: 21},
-                      t.atoms.text_contrast_medium,
-                      web({whiteSpace: 'preserve nowrap'}),
-                    ]}>
-                    {' '}
-                    &middot;{' '}
-                    <BellStroke
-                      size="xs"
-                      style={[t.atoms.text_contrast_medium]}
-                    />
+                    numberOfLines={1}
+                    style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
+                    @{profile.handle}
                   </Text>
                 )}
-              </View>
 
-              {!isDeletedAccount && (
                 <Text
-                  numberOfLines={1}
-                  style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}>
-                  @{profile.handle}
+                  emoji
+                  numberOfLines={2}
+                  style={[
+                    a.text_sm,
+                    a.leading_snug,
+                    convo.unreadCount > 0
+                      ? a.font_bold
+                      : t.atoms.text_contrast_high,
+                    isDimStyle && t.atoms.text_contrast_medium,
+                  ]}>
+                  {lastMessage}
                 </Text>
-              )}
 
-              <Text
-                emoji
-                numberOfLines={2}
-                style={[
-                  a.text_sm,
-                  a.leading_snug,
-                  convo.unreadCount > 0
-                    ? a.font_bold
-                    : t.atoms.text_contrast_high,
-                  isDimStyle && t.atoms.text_contrast_medium,
-                ]}>
-                {lastMessage}
-              </Text>
-
-              <PostAlerts
-                modui={moderation.ui('contentList')}
-                size="lg"
-                style={[a.pt_xs]}
-              />
+                <PostAlerts
+                  modui={moderation.ui('contentList')}
+                  size="lg"
+                  style={[a.pt_xs]}
+                />
+              </View>
+
+              {convo.unreadCount > 0 && (
+                <View
+                  style={[
+                    a.absolute,
+                    a.rounded_full,
+                    {
+                      backgroundColor: isDimStyle
+                        ? t.palette.contrast_200
+                        : t.palette.primary_500,
+                      height: 7,
+                      width: 7,
+                      top: 15,
+                      right: 12,
+                    },
+                  ]}
+                />
+              )}
             </View>
+          )}
+        </Link>
 
-            {convo.unreadCount > 0 && (
-              <View
-                style={[
-                  a.absolute,
-                  a.rounded_full,
-                  {
-                    backgroundColor: isDimStyle
-                      ? t.palette.contrast_200
-                      : t.palette.primary_500,
-                    height: 7,
-                    width: 7,
-                    top: 15,
-                    right: 12,
-                  },
-                ]}
-              />
-            )}
-          </View>
-        )}
-      </Link>
-
-      <ConvoMenu
-        convo={convo}
-        profile={profile}
-        control={menuControl}
-        currentScreen="list"
-        showMarkAsRead={convo.unreadCount > 0}
-        hideTrigger={isNative}
-        blockInfo={blockInfo}
-        style={[
-          a.absolute,
-          a.h_full,
-          a.self_end,
-          a.justify_center,
-          {
-            right: tokens.space.lg,
-            opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
-          },
-        ]}
-      />
-    </View>
+        <ConvoMenu
+          convo={convo}
+          profile={profile}
+          control={menuControl}
+          currentScreen="list"
+          showMarkAsRead={convo.unreadCount > 0}
+          hideTrigger={isNative}
+          blockInfo={blockInfo}
+          style={[
+            a.absolute,
+            a.h_full,
+            a.self_end,
+            a.justify_center,
+            {
+              right: tokens.space.lg,
+              opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
+            },
+          ]}
+        />
+        <LeaveConvoPrompt
+          control={leaveConvoControl}
+          convoId={convo.id}
+          currentScreen="list"
+        />
+      </View>
+    </GestureActionView>
   )
 }