about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-05-02 13:54:17 -0700
committerGitHub <noreply@github.com>2024-05-02 13:54:17 -0700
commit8ba1b10ce0d278a88e37d6b6c277a41673392877 (patch)
tree1e44ec05c427aaa124077b6fb9ce01c831daa8b3
parent6da18e3dcffaf72a03bde8a205a596b4b3366b86 (diff)
downloadvoidsky-8ba1b10ce0d278a88e37d6b6c277a41673392877.tar.zst
[Clipclops] Message actions for native and web (#3807)
* haptic on long press

* add animation to press and hold

* eslint disable for now

* adjust styles

* dont trigger if animation is cancelled

* organize

* add a delete menu

* reset scale automatically

* message actions dialog

cleanup

center the trigger

handle focus/unfocus better

make triggers accessible

weg dropdown menu

add a wep specific wrapper

decrease press delay

add report button

improve shrink logic

use `self_end` instead of `margin: auto`

rm extra `?`

move `MessageItem` to `components`

add delete button

* rm some padding

* update after merge

* fix merge

* web only types

* fix crash

* add an explanation

* fix web types

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
-rw-r--r--src/components/dms/ActionsWrapper.tsx82
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx86
-rw-r--r--src/components/dms/MessageItem.tsx (renamed from src/screens/Messages/Conversation/MessageItem.tsx)57
-rw-r--r--src/components/dms/MessageMenu.tsx99
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx2
5 files changed, 297 insertions, 29 deletions
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
new file mode 100644
index 000000000..107e5eb8e
--- /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 (
+    <View
+      style={[
+        {
+          maxWidth: '65%',
+        },
+        isFromSelf ? a.self_end : a.self_start,
+      ]}>
+      <AnimatedPressable
+        style={animatedStyle}
+        unstable_pressDelay={200}
+        onPressIn={grow}
+        onTouchEnd={shrink}>
+        {children}
+      </AnimatedPressable>
+      <MessageMenu message={message} control={menuControl} hideTrigger={true} />
+    </View>
+  )
+}
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
new file mode 100644
index 000000000..f4c85ab94
--- /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<React.FocusEventHandler>(e => {
+    if (e.nativeEvent.relatedTarget == null) return
+    setShowActions(true)
+  }, [])
+
+  return (
+    <View
+      // @ts-expect-error web only
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onMouseLeave}
+      style={StyleSheet.flatten([a.flex_1, a.flex_row])}
+      ref={viewRef}>
+      {isFromSelf && (
+        <View
+          style={[
+            a.mr_xl,
+            a.justify_center,
+            {
+              marginLeft: 'auto',
+            },
+          ]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+      <View
+        style={{
+          maxWidth: '65%',
+        }}>
+        {children}
+      </View>
+      {!isFromSelf && (
+        <View style={[a.flex_row, a.align_center, a.ml_xl]}>
+          <MessageMenu
+            message={message}
+            control={menuControl}
+            triggerOpacity={showActions || menuControl.isOpen ? 1 : 0}
+            onTriggerPress={onMouseEnter}
+            // @ts-expect-error web only
+            onMouseLeave={onMouseLeave}
+          />
+        </View>
+      )}
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index ba1bcfd39..3a1d8eab7 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 (
     <View>
-      <View
-        style={[
-          a.py_sm,
-          a.px_lg,
-          a.my_2xs,
-          a.rounded_md,
-          isFromSelf ? a.self_end : a.self_start,
-          {
-            maxWidth: '65%',
-            backgroundColor: isFromSelf
-              ? t.palette.primary_500
-              : t.palette.contrast_50,
-            borderRadius: 17,
-          },
-          isFromSelf
-            ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
-            : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
-        ]}>
-        <Text
+      <ActionsWrapper isFromSelf={isFromSelf} message={item}>
+        <View
           style={[
-            a.text_md,
-            a.leading_snug,
-            isFromSelf && {color: t.palette.white},
+            a.py_sm,
+            a.px_lg,
+            a.my_2xs,
+            a.rounded_md,
+            {
+              backgroundColor: isFromSelf
+                ? t.palette.primary_500
+                : t.palette.contrast_50,
+              borderRadius: 17,
+            },
+            isFromSelf
+              ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
+              : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
           ]}>
-          {item.text}
-        </Text>
-      </View>
-      <Metadata
+          <Text
+            style={[
+              a.text_md,
+              a.leading_snug,
+              isFromSelf && {color: t.palette.white},
+            ]}>
+            {item.text}
+          </Text>
+        </View>
+      </ActionsWrapper>
+      <MessageItemMetadata
         message={item}
         isLastInGroup={isLastInGroup}
         style={isFromSelf ? a.text_right : a.text_left}
@@ -86,7 +87,7 @@ export function MessageItem({
   )
 }
 
-function Metadata({
+export function MessageItemMetadata({
   message,
   isLastInGroup,
   style,
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx
new file mode 100644
index 000000000..a21324204
--- /dev/null
+++ b/src/components/dms/MessageMenu.tsx
@@ -0,0 +1,99 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {usePromptControl} from '#/components/Prompt'
+
+export let MessageMenu = ({
+  message,
+  control,
+  hideTrigger,
+  triggerOpacity,
+}: {
+  hideTrigger?: boolean
+  triggerOpacity?: number
+  onTriggerPress?: () => 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 (
+    <>
+      <Menu.Root control={control}>
+        {!hideTrigger && (
+          <View style={{opacity: triggerOpacity}}>
+            <Menu.Trigger label={_(msg`Chat settings`)}>
+              {({props, state}) => (
+                <Pressable
+                  {...props}
+                  style={[
+                    a.p_sm,
+                    a.rounded_full,
+                    (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                  ]}>
+                  <DotsHorizontal size="sm" style={t.atoms.text} />
+                </Pressable>
+              )}
+            </Menu.Trigger>
+          </View>
+        )}
+
+        <Menu.Outer>
+          <Menu.Group>
+            <Menu.Item
+              testID="messageDropdownDeleteBtn"
+              label={_(msg`Delete message`)}
+              onPress={deleteControl.open}>
+              <Menu.ItemText>{_(msg`Delete`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={Trash} position="right" />
+            </Menu.Item>
+            {!isFromSelf && (
+              <Menu.Item
+                testID="messageDropdownReportBtn"
+                label={_(msg`Report message`)}
+                onPress={onReport}>
+                <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Warning} position="right" />
+              </Menu.Item>
+            )}
+          </Menu.Group>
+        </Menu.Outer>
+      </Menu.Root>
+
+      <Prompt.Basic
+        control={deleteControl}
+        title={_(msg`Delete message`)}
+        description={_(
+          msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
+        )}
+        confirmButtonCta={_(msg`Delete`)}
+        confirmButtonColor="negative"
+        onConfirm={onDelete}
+      />
+    </>
+  )
+}
+MessageMenu = React.memo(MessageMenu)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 1a6145da5..435c40326 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'