about summary refs log tree commit diff
path: root/src/components/dms
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-03-28 08:43:40 +0200
committerGitHub <noreply@github.com>2025-03-28 08:43:40 +0200
commit55a40c2436b68dea850e54a65c5dd197132c08e4 (patch)
treee6d4d2d45ce5a3475aa4f73556910ff7d818986f /src/components/dms
parentac2c2a9a1d2d09442a497dc0dcfd8bc0bf715372 (diff)
downloadvoidsky-55a40c2436b68dea850e54a65c5dd197132c08e4.tar.zst
[DMs] Emoji reaction picker (#8023)
Diffstat (limited to 'src/components/dms')
-rw-r--r--src/components/dms/ActionsWrapper.tsx24
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx40
-rw-r--r--src/components/dms/EmojiPopup.android.tsx82
-rw-r--r--src/components/dms/EmojiPopup.tsx1
-rw-r--r--src/components/dms/EmojiReactionPicker.tsx118
-rw-r--r--src/components/dms/EmojiReactionPicker.web.tsx86
-rw-r--r--src/components/dms/MessageContextMenu.tsx12
7 files changed, 333 insertions, 30 deletions
diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx
index 385086d7c..120a5f8ad 100644
--- a/src/components/dms/ActionsWrapper.tsx
+++ b/src/components/dms/ActionsWrapper.tsx
@@ -23,28 +23,6 @@ export function ActionsWrapper({
         // will always be true, since this file is platform split
         trigger.isNative && (
           <View style={[a.flex_1, a.relative]}>
-            {/* {isNative && (
-              <View
-                style={[
-                  a.rounded_full,
-                  a.absolute,
-                  {bottom: '100%'},
-                  isFromSelf ? a.right_0 : a.left_0,
-                  t.atoms.bg,
-                  a.flex_row,
-                  a.shadow_lg,
-                  a.py_xs,
-                  a.px_md,
-                  a.gap_md,
-                  a.mb_xs,
-                ]}>
-                {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
-                  <Text key={emoji} style={[a.text_center, {fontSize: 32}]}>
-                    {emoji}
-                  </Text>
-                ))}
-              </View>
-            )} */}
             <View
               style={[
                 {maxWidth: '80%'},
@@ -56,7 +34,7 @@ export function ActionsWrapper({
               accessibilityActions={[
                 {name: 'activate', label: _(msg`Open message options`)},
               ]}
-              onAccessibilityAction={trigger.control.open}>
+              onAccessibilityAction={() => trigger.control.open('full')}>
               {children}
             </View>
           </View>
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 188d18eb7..82113eba8 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -4,7 +4,9 @@ import {ChatBskyConvoDefs} from '@atproto/api'
 
 import {atoms as a, useTheme} from '#/alf'
 import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
-import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '../icons/DotGrid'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
+import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
+import {EmojiReactionPicker} from './EmojiReactionPicker'
 
 export function ActionsWrapper({
   message,
@@ -47,10 +49,35 @@ export function ActionsWrapper({
       <View
         style={[
           a.justify_center,
+          a.flex_row,
+          a.align_center,
+          a.gap_xs,
           isFromSelf
-            ? [a.mr_xl, {marginLeft: 'auto'}]
-            : [a.ml_xl, {marginRight: 'auto'}],
+            ? [a.mr_md, {marginLeft: 'auto'}]
+            : [a.ml_md, {marginRight: 'auto'}],
         ]}>
+        <EmojiReactionPicker message={message}>
+          {({props, state, isNative, control}) => {
+            // always false, file is platform split
+            if (isNative) return null
+            const showMenuTrigger = showActions || control.isOpen ? 1 : 0
+            return (
+              <Pressable
+                {...props}
+                style={[
+                  {opacity: showMenuTrigger},
+                  a.p_xs,
+                  a.rounded_full,
+                  (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <EmojiSmileIcon
+                  size="md"
+                  style={t.atoms.text_contrast_medium}
+                />
+              </Pressable>
+            )
+          }}
+        </EmojiReactionPicker>
         <MessageContextMenu message={message}>
           {({props, state, isNative, control}) => {
             // always false, file is platform split
@@ -61,11 +88,14 @@ export function ActionsWrapper({
                 {...props}
                 style={[
                   {opacity: showMenuTrigger},
-                  a.p_sm,
+                  a.p_xs,
                   a.rounded_full,
                   (state.hovered || state.pressed) && t.atoms.bg_contrast_25,
                 ]}>
-                <DotsHorizontalIcon size="md" style={t.atoms.text} />
+                <DotsHorizontalIcon
+                  size="md"
+                  style={t.atoms.text_contrast_medium}
+                />
               </Pressable>
             )
           }}
diff --git a/src/components/dms/EmojiPopup.android.tsx b/src/components/dms/EmojiPopup.android.tsx
new file mode 100644
index 000000000..05369cf3e
--- /dev/null
+++ b/src/components/dms/EmojiPopup.android.tsx
@@ -0,0 +1,82 @@
+import {useState} from 'react'
+import {Modal, Pressable, View} from 'react-native'
+// @ts-expect-error internal component, not supposed to be used directly
+// waiting on more customisability: https://github.com/okwasniewski/react-native-emoji-popup/issues/1#issuecomment-2737463753
+import EmojiPopupView from 'react-native-emoji-popup/src/EmojiPopupViewNativeComponent'
+import {Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+export function EmojiPopup({
+  children,
+  onEmojiSelected,
+}: {
+  children: React.ReactNode
+  onEmojiSelected: (emoji: string) => void
+}) {
+  const [modalVisible, setModalVisible] = useState(false)
+  const {_} = useLingui()
+  const t = useTheme()
+
+  return (
+    <>
+      <Pressable
+        accessibilityLabel={_('Open full emoji list')}
+        accessibilityHint=""
+        accessibilityRole="button"
+        onPress={() => setModalVisible(true)}>
+        {children}
+      </Pressable>
+
+      <Modal
+        animationType="slide"
+        transparent={true}
+        visible={modalVisible}
+        onRequestClose={() => setModalVisible(false)}>
+        <View style={[a.flex_1, {backgroundColor: t.palette.white}]}>
+          <View
+            style={[
+              t.atoms.bg,
+              a.pl_lg,
+              a.pr_md,
+              a.py_sm,
+              a.w_full,
+              a.align_center,
+              a.flex_row,
+              a.justify_between,
+              a.border_b,
+              t.atoms.border_contrast_low,
+            ]}>
+            <Text style={[a.font_bold, a.text_md]}>
+              <Trans>Add Reaction</Trans>
+            </Text>
+            <Button
+              label={_('Close')}
+              onPress={() => setModalVisible(false)}
+              size="small"
+              variant="ghost"
+              color="secondary"
+              shape="round">
+              <ButtonIcon icon={TimesLarge_Stroke2_Corner0_Rounded} />
+            </Button>
+          </View>
+          <EmojiPopupView
+            onEmojiSelected={({
+              nativeEvent: {emoji},
+            }: {
+              nativeEvent: {emoji: string}
+            }) => {
+              setModalVisible(false)
+              onEmojiSelected(emoji)
+            }}
+            style={[a.flex_1, a.w_full]}
+          />
+        </View>
+      </Modal>
+    </>
+  )
+}
diff --git a/src/components/dms/EmojiPopup.tsx b/src/components/dms/EmojiPopup.tsx
new file mode 100644
index 000000000..a8f2f83e7
--- /dev/null
+++ b/src/components/dms/EmojiPopup.tsx
@@ -0,0 +1 @@
+export {EmojiPopup} from 'react-native-emoji-popup'
diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx
new file mode 100644
index 000000000..a98cebf9a
--- /dev/null
+++ b/src/components/dms/EmojiReactionPicker.tsx
@@ -0,0 +1,118 @@
+import {useMemo, useState} from 'react'
+import {Alert, useWindowDimensions, View} from 'react-native'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useSession} from '#/state/session'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import * as ContextMenu from '#/components/ContextMenu'
+import {
+  useContextMenuContext,
+  useContextMenuMenuContext,
+} from '#/components/ContextMenu/context'
+import {
+  EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyesIcon,
+  EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon,
+} from '#/components/icons/Emoji'
+import {type TriggerProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+import {EmojiPopup} from './EmojiPopup'
+
+export function EmojiReactionPicker({
+  message,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  children?: TriggerProps['children']
+}) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const t = useTheme()
+  const isFromSelf = message.sender?.did === currentAccount?.did
+  const {measurement, close} = useContextMenuContext()
+  const {align} = useContextMenuMenuContext()
+  const [layout, setLayout] = useState({width: 0, height: 0})
+  const {width: screenWidth} = useWindowDimensions()
+
+  // 1 in 100 chance of showing heart eyes icon
+  const EmojiIcon = useMemo(() => {
+    return Math.random() < 0.01 ? EmojiHeartEyesIcon : EmojiSmileIcon
+  }, [])
+
+  const handleEmojiSelect = (emoji: string) => {
+    Alert.alert(emoji)
+  }
+
+  const position = useMemo(() => {
+    return {
+      x: align === 'left' ? 12 : screenWidth - layout.width - 12,
+      y: (measurement?.y ?? 0) - tokens.space.xs - layout.height,
+      height: layout.height,
+      width: layout.width,
+    }
+  }, [measurement, align, screenWidth, layout])
+
+  return (
+    <View
+      onLayout={evt => setLayout(evt.nativeEvent.layout)}
+      style={[
+        a.rounded_full,
+        a.absolute,
+        {bottom: '100%'},
+        isFromSelf ? a.right_0 : a.left_0,
+        t.scheme === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
+        a.flex_row,
+        a.p_xs,
+        a.gap_xs,
+        a.mb_xs,
+        a.z_20,
+        a.border,
+        t.atoms.border_contrast_low,
+        a.shadow_md,
+      ]}>
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+        <ContextMenu.Item
+          position={position}
+          label={_(msg`React with ${emoji}`)}
+          key={emoji}
+          onPress={() => handleEmojiSelect(emoji)}
+          unstyled>
+          {hovered => (
+            <View
+              style={[
+                a.rounded_full,
+                hovered && {backgroundColor: t.palette.primary_500},
+                {height: 40, width: 40},
+                a.justify_center,
+                a.align_center,
+              ]}>
+              <Text style={[a.text_center, {fontSize: 30}]} emoji>
+                {emoji}
+              </Text>
+            </View>
+          )}
+        </ContextMenu.Item>
+      ))}
+      <EmojiPopup
+        onEmojiSelected={emoji => {
+          close()
+          handleEmojiSelect(emoji)
+        }}>
+        <View
+          style={[
+            a.rounded_full,
+            t.scheme === 'light'
+              ? t.atoms.bg_contrast_25
+              : t.atoms.bg_contrast_50,
+            {height: 40, width: 40},
+            a.justify_center,
+            a.align_center,
+            a.border,
+            t.atoms.border_contrast_low,
+          ]}>
+          <EmojiIcon size="xl" fill={t.palette.contrast_400} />
+        </View>
+      </EmojiPopup>
+    </View>
+  )
+}
diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx
new file mode 100644
index 000000000..bd51b4fd2
--- /dev/null
+++ b/src/components/dms/EmojiReactionPicker.web.tsx
@@ -0,0 +1,86 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+import {ChatBskyConvoDefs} from '@atproto/api'
+import EmojiPicker from '@emoji-mart/react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Emoji} from '#/view/com/composer/text-input/web/EmojiPicker.web'
+import {PressableWithHover} from '#/view/com/util/PressableWithHover'
+import {atoms as a} from '#/alf'
+import {useTheme} from '#/alf'
+import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid'
+import * as Menu from '#/components/Menu'
+import {TriggerProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+
+export function EmojiReactionPicker({
+  children,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  children?: TriggerProps['children']
+}) {
+  if (!children)
+    throw new Error('EmojiReactionPicker requires the children prop on web')
+
+  const {_} = useLingui()
+
+  return (
+    <Menu.Root>
+      <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
+      <Menu.Outer>
+        <MenuInner />
+      </Menu.Outer>
+    </Menu.Root>
+  )
+}
+
+function MenuInner() {
+  const t = useTheme()
+  const {control} = Menu.useMenuContext()
+
+  const [expanded, setExpanded] = useState(false)
+
+  const handleEmojiPickerResponse = (emoji: Emoji) => {
+    handleEmojiSelect(emoji.native)
+  }
+
+  const handleEmojiSelect = (emoji: string) => {
+    control.close()
+    window.alert(emoji)
+  }
+
+  return expanded ? (
+    <EmojiPicker onEmojiSelect={handleEmojiPickerResponse} autoFocus={true} />
+  ) : (
+    <View style={[a.flex_row, a.gap_xs]}>
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => (
+        <PressableWithHover
+          key={emoji}
+          onPress={() => handleEmojiSelect(emoji)}
+          hoverStyle={{backgroundColor: t.palette.primary_100}}
+          style={[
+            a.rounded_xs,
+            {height: 40, width: 40},
+            a.justify_center,
+            a.align_center,
+          ]}>
+          <Text style={[a.text_center, {fontSize: 30}]} emoji>
+            {emoji}
+          </Text>
+        </PressableWithHover>
+      ))}
+      <PressableWithHover
+        onPress={() => setExpanded(true)}
+        hoverStyle={{backgroundColor: t.palette.primary_100}}
+        style={[
+          a.rounded_xs,
+          {height: 40, width: 40},
+          a.justify_center,
+          a.align_center,
+        ]}>
+        <DotGridIcon size="lg" style={t.atoms.text_contrast_medium} />
+      </PressableWithHover>
+    </View>
+  )
+}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index b5542690f..5591bec69 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -1,19 +1,20 @@
 import React from 'react'
 import {LayoutAnimation} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
-import {ChatBskyConvoDefs, RichText} from '@atproto/api'
+import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
+import {isNative} from '#/platform/detection'
 import {useConvoActive} from '#/state/messages/convo'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import * as ContextMenu from '#/components/ContextMenu'
-import {TriggerProps} from '#/components/ContextMenu/types'
+import {type TriggerProps} from '#/components/ContextMenu/types'
 import {ReportDialog} from '#/components/dms/ReportDialog'
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
@@ -21,6 +22,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
 import * as Prompt from '#/components/Prompt'
 import {usePromptControl} from '#/components/Prompt'
+import {EmojiReactionPicker} from './EmojiReactionPicker'
 
 export let MessageContextMenu = ({
   message,
@@ -77,6 +79,12 @@ export let MessageContextMenu = ({
   return (
     <>
       <ContextMenu.Root>
+        {isNative && (
+          <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
+            <EmojiReactionPicker message={message} />
+          </ContextMenu.AuxiliaryView>
+        )}
+
         <ContextMenu.Trigger
           label={_(msg`Message options`)}
           contentLabel={_(