diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
new file mode 100644
index 00000000..107e5eb8
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -0,0 +1,82 @@
+import React, {useCallback} from 'react'
+import {Pressable, View} from 'react-native'
+import Animated, {
+ cancelAnimation,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {useHaptics} from 'lib/haptics'
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+export function ActionsWrapper({
+ message,
+ isFromSelf,
+ children,
+}: {
+ message: ChatBskyConvoDefs.MessageView
+ isFromSelf: boolean
+ children: React.ReactNode
+}) {
+ const playHaptic = useHaptics()
+ const menuControl = useMenuControl()
+
+ const scale = useSharedValue(1)
+ const animationDidComplete = useSharedValue(false)
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{scale: scale.value}],
+ }))
+
+ // Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
+ // function
+ const open = useCallback(() => {
+ menuControl.open()
+ }, [menuControl])
+
+ const shrink = useCallback(() => {
+ 'worklet'
+ cancelAnimation(scale)
+ scale.value = withTiming(1, {duration: 200}, () => {
+ animationDidComplete.value = false
+ })
+ }, [animationDidComplete, scale])
+
+ const grow = React.useCallback(() => {
+ 'worklet'
+ scale.value = withTiming(1.05, {duration: 750}, finished => {
+ if (!finished) return
+ animationDidComplete.value = true
+ runOnJS(playHaptic)()
+ runOnJS(open)()
+
+ shrink()
+ })
+ }, [scale, animationDidComplete, playHaptic, shrink, open])
+
+ return (
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
new file mode 100644
index 00000000..f4c85ab9
--- /dev/null
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+
+import {atoms as a} from '#/alf'
+import {MessageMenu} from '#/components/dms/MessageMenu'
+import {useMenuControl} from '#/components/Menu'
+
+export function ActionsWrapper({
+ message,
+ isFromSelf,
+ children,
+}: {
+ message: ChatBskyConvoDefs.MessageView
+ isFromSelf: boolean
+ children: React.ReactNode
+}) {
+ const menuControl = useMenuControl()
+ const viewRef = React.useRef(null)
+
+ const [showActions, setShowActions] = React.useState(false)
+
+ const onMouseEnter = React.useCallback(() => {
+ setShowActions(true)
+ }, [])
+
+ const onMouseLeave = React.useCallback(() => {
+ setShowActions(false)
+ }, [])
+
+ // We need to handle the `onFocus` separately because we want to know if there is a related target (the element
+ // that is losing focus). If there isn't that means the focus is coming from a dropdown that is now closed.
+ const onFocus = React.useCallback(e => {
+ if (e.nativeEvent.relatedTarget == null) return
+ setShowActions(true)
+ }, [])
+
+ return (
+
+ {isFromSelf && (
+
+
+
+ )}
+
+ {children}
+
+ {!isFromSelf && (
+
+
+
+ )}
+
+ )
+}
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/components/dms/MessageItem.tsx
similarity index 77%
rename from src/screens/Messages/Conversation/MessageItem.tsx
rename to src/components/dms/MessageItem.tsx
index ba1bcfd3..3a1d8eab 100644
--- a/src/screens/Messages/Conversation/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -5,8 +5,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
-import {TimeElapsed} from '#/view/com/util/TimeElapsed'
+import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
+import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography'
export function MessageItem({
@@ -50,34 +51,34 @@ export function MessageItem({
return (
-
-
+
- {item.text}
-
-
-
+ {item.text}
+
+
+
+ void
+ message: ChatBskyConvoDefs.MessageView
+ control: Menu.MenuControlProps
+}): React.ReactNode => {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const deleteControl = usePromptControl()
+
+ const isFromSelf = message.sender?.did === currentAccount?.did
+
+ const onDelete = React.useCallback(() => {
+ // TODO delete the message
+ }, [])
+
+ const onReport = React.useCallback(() => {
+ // TODO report the message
+ }, [])
+
+ return (
+ <>
+
+ {!hideTrigger && (
+
+
+ {({props, state}) => (
+
+
+
+ )}
+
+
+ )}
+
+
+
+
+ {_(msg`Delete`)}
+
+
+ {!isFromSelf && (
+
+ {_(msg`Report`)}
+
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+MessageMenu = React.memo(MessageMenu)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 1a6145da..435c4032 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -17,9 +17,9 @@ import {useChat} from '#/state/messages'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
import {useSetMinimalShellMode} from '#/state/shell'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
-import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
+import {MessageItem} from '#/components/dms/MessageItem'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'