about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ContextMenu/index.tsx5
-rw-r--r--src/components/dms/ActionsWrapper.web.tsx50
-rw-r--r--src/components/dms/EmojiReactionPicker.tsx75
-rw-r--r--src/components/dms/EmojiReactionPicker.web.tsx72
-rw-r--r--src/components/dms/MessageContextMenu.tsx40
-rw-r--r--src/components/dms/MessageItem.tsx93
-rw-r--r--src/components/dms/util.ts31
7 files changed, 294 insertions, 72 deletions
diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx
index 90c448782..aebed6419 100644
--- a/src/components/ContextMenu/index.tsx
+++ b/src/components/ContextMenu/index.tsx
@@ -775,7 +775,10 @@ export function Item({
       ]}>
       <ItemContext.Provider value={itemContext}>
         {typeof children === 'function'
-          ? children(focused || pressed || context.hoveredMenuItem === id)
+          ? children(
+              (focused || pressed || context.hoveredMenuItem === id) &&
+                !rest.disabled,
+            )
           : children}
       </ItemContext.Provider>
     </Pressable>
diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx
index 82113eba8..aaffc0cfb 100644
--- a/src/components/dms/ActionsWrapper.web.tsx
+++ b/src/components/dms/ActionsWrapper.web.tsx
@@ -1,12 +1,19 @@
-import React from 'react'
+import {useCallback, useRef, useState} from 'react'
 import {Pressable, View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import type React from 'react'
 
+import {useConvoActive} from '#/state/messages/convo'
+import {useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
 import {MessageContextMenu} from '#/components/dms/MessageContextMenu'
 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'
+import {hasReachedReactionLimit} from './util'
 
 export function ActionsWrapper({
   message,
@@ -17,26 +24,53 @@ export function ActionsWrapper({
   isFromSelf: boolean
   children: React.ReactNode
 }) {
-  const viewRef = React.useRef(null)
+  const viewRef = useRef(null)
   const t = useTheme()
+  const {_} = useLingui()
+  const convo = useConvoActive()
+  const {currentAccount} = useSession()
 
-  const [showActions, setShowActions] = React.useState(false)
+  const [showActions, setShowActions] = useState(false)
 
-  const onMouseEnter = React.useCallback(() => {
+  const onMouseEnter = useCallback(() => {
     setShowActions(true)
   }, [])
 
-  const onMouseLeave = React.useCallback(() => {
+  const onMouseLeave = 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 => {
+  const onFocus = useCallback<React.FocusEventHandler>(e => {
     if (e.nativeEvent.relatedTarget == null) return
     setShowActions(true)
   }, [])
 
+  const onEmojiSelect = useCallback(
+    (emoji: string) => {
+      if (
+        message.reactions?.find(
+          reaction =>
+            reaction.value === emoji &&
+            reaction.sender.did === currentAccount?.did,
+        )
+      ) {
+        convo
+          .removeReaction(message.id, emoji)
+          .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
+      } else {
+        if (hasReachedReactionLimit(message, currentAccount?.did)) return
+        convo
+          .addReaction(message.id, emoji)
+          .catch(() =>
+            Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
+          )
+      }
+    },
+    [_, convo, message, currentAccount?.did],
+  )
+
   return (
     <View
       // @ts-expect-error web only
@@ -56,7 +90,7 @@ export function ActionsWrapper({
             ? [a.mr_md, {marginLeft: 'auto'}]
             : [a.ml_md, {marginRight: 'auto'}],
         ]}>
-        <EmojiReactionPicker message={message}>
+        <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect}>
           {({props, state, isNative, control}) => {
             // always false, file is platform split
             if (isNative) return null
diff --git a/src/components/dms/EmojiReactionPicker.tsx b/src/components/dms/EmojiReactionPicker.tsx
index a98cebf9a..477f45743 100644
--- a/src/components/dms/EmojiReactionPicker.tsx
+++ b/src/components/dms/EmojiReactionPicker.tsx
@@ -1,5 +1,5 @@
 import {useMemo, useState} from 'react'
-import {Alert, useWindowDimensions, View} from 'react-native'
+import {useWindowDimensions, View} from 'react-native'
 import {type ChatBskyConvoDefs} from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -18,12 +18,15 @@ import {
 import {type TriggerProps} from '#/components/Menu/types'
 import {Text} from '#/components/Typography'
 import {EmojiPopup} from './EmojiPopup'
+import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
 
 export function EmojiReactionPicker({
   message,
+  onEmojiSelect,
 }: {
   message: ChatBskyConvoDefs.MessageView
   children?: TriggerProps['children']
+  onEmojiSelect: (emoji: string) => void
 }) {
   const {_} = useLingui()
   const {currentAccount} = useSession()
@@ -39,10 +42,6 @@ export function EmojiReactionPicker({
     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,
@@ -52,6 +51,8 @@ export function EmojiReactionPicker({
     }
   }, [measurement, align, screenWidth, layout])
 
+  const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+
   return (
     <View
       onLayout={evt => setLayout(evt.nativeEvent.layout)}
@@ -70,33 +71,49 @@ export function EmojiReactionPicker({
         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>
-      ))}
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
+        const alreadyReacted = hasAlreadyReacted(
+          message,
+          currentAccount?.did,
+          emoji,
+        )
+        return (
+          <ContextMenu.Item
+            position={position}
+            label={_(msg`React with ${emoji}`)}
+            key={emoji}
+            onPress={() => onEmojiSelect(emoji)}
+            unstyled
+            disabled={limitReacted ? !alreadyReacted : false}>
+            {hovered => (
+              <View
+                style={[
+                  a.rounded_full,
+                  hovered
+                    ? {
+                        backgroundColor: alreadyReacted
+                          ? t.palette.negative_100
+                          : t.palette.primary_500,
+                      }
+                    : alreadyReacted && {
+                        backgroundColor: t.palette.primary_200,
+                      },
+                  {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)
+          onEmojiSelect(emoji)
         }}>
         <View
           style={[
diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx
index bd51b4fd2..0425a879a 100644
--- a/src/components/dms/EmojiReactionPicker.web.tsx
+++ b/src/components/dms/EmojiReactionPicker.web.tsx
@@ -1,24 +1,29 @@
 import {useState} from 'react'
 import {View} from 'react-native'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type 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 {useSession} from '#/state/session'
+import {type 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 {type TriggerProps} from '#/components/Menu/types'
 import {Text} from '#/components/Typography'
+import {hasAlreadyReacted, hasReachedReactionLimit} from './util'
 
 export function EmojiReactionPicker({
+  message,
   children,
+  onEmojiSelect,
 }: {
   message: ChatBskyConvoDefs.MessageView
   children?: TriggerProps['children']
+  onEmojiSelect: (emoji: string) => void
 }) {
   if (!children)
     throw new Error('EmojiReactionPicker requires the children prop on web')
@@ -29,15 +34,22 @@ export function EmojiReactionPicker({
     <Menu.Root>
       <Menu.Trigger label={_(msg`Add emoji reaction`)}>{children}</Menu.Trigger>
       <Menu.Outer>
-        <MenuInner />
+        <MenuInner message={message} onEmojiSelect={onEmojiSelect} />
       </Menu.Outer>
     </Menu.Root>
   )
 }
 
-function MenuInner() {
+function MenuInner({
+  message,
+  onEmojiSelect,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  onEmojiSelect: (emoji: string) => void
+}) {
   const t = useTheme()
   const {control} = Menu.useMenuContext()
+  const {currentAccount} = useSession()
 
   const [expanded, setExpanded] = useState(false)
 
@@ -47,29 +59,45 @@ function MenuInner() {
 
   const handleEmojiSelect = (emoji: string) => {
     control.close()
-    window.alert(emoji)
+    onEmojiSelect(emoji)
   }
 
+  const limitReacted = hasReachedReactionLimit(message, currentAccount?.did)
+
   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>
-      ))}
+      {['👍', '😆', '❤️', '👀', '😢'].map(emoji => {
+        const alreadyReacted = hasAlreadyReacted(
+          message,
+          currentAccount?.did,
+          emoji,
+        )
+        return (
+          <PressableWithHover
+            key={emoji}
+            onPress={() => handleEmojiSelect(emoji)}
+            hoverStyle={{
+              backgroundColor: alreadyReacted
+                ? t.palette.negative_200
+                : !limitReacted
+                ? t.palette.primary_300
+                : undefined,
+            }}
+            style={[
+              a.rounded_xs,
+              {height: 40, width: 40},
+              a.justify_center,
+              a.align_center,
+              alreadyReacted && {backgroundColor: t.palette.primary_100},
+            ]}>
+            <Text style={[a.text_center, {fontSize: 30}]} emoji>
+              {emoji}
+            </Text>
+          </PressableWithHover>
+        )
+      })}
       <PressableWithHover
         onPress={() => setExpanded(true)}
         hoverStyle={{backgroundColor: t.palette.primary_100}}
diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx
index 5591bec69..c978f1556 100644
--- a/src/components/dms/MessageContextMenu.tsx
+++ b/src/components/dms/MessageContextMenu.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import {memo, useCallback} from 'react'
 import {LayoutAnimation} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
 import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
@@ -23,6 +23,7 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
 import * as Prompt from '#/components/Prompt'
 import {usePromptControl} from '#/components/Prompt'
 import {EmojiReactionPicker} from './EmojiReactionPicker'
+import {hasReachedReactionLimit} from './util'
 
 export let MessageContextMenu = ({
   message,
@@ -41,7 +42,7 @@ export let MessageContextMenu = ({
 
   const isFromSelf = message.sender?.did === currentAccount?.did
 
-  const onCopyMessage = React.useCallback(() => {
+  const onCopyMessage = useCallback(() => {
     const str = richTextToString(
       new RichText({
         text: message.text,
@@ -54,7 +55,7 @@ export let MessageContextMenu = ({
     Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
   }, [_, message.text, message.facets])
 
-  const onPressTranslateMessage = React.useCallback(() => {
+  const onPressTranslateMessage = useCallback(() => {
     const translatorUrl = getTranslatorLink(
       message.text,
       langPrefs.primaryLanguage,
@@ -62,7 +63,7 @@ export let MessageContextMenu = ({
     openLink(translatorUrl, true)
   }, [langPrefs.primaryLanguage, message.text, openLink])
 
-  const onDelete = React.useCallback(() => {
+  const onDelete = useCallback(() => {
     LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
     convo
       .deleteMessage(message.id)
@@ -72,6 +73,30 @@ export let MessageContextMenu = ({
       .catch(() => Toast.show(_(msg`Failed to delete message`)))
   }, [_, convo, message.id])
 
+  const onEmojiSelect = useCallback(
+    (emoji: string) => {
+      if (
+        message.reactions?.find(
+          reaction =>
+            reaction.value === emoji &&
+            reaction.sender.did === currentAccount?.did,
+        )
+      ) {
+        convo
+          .removeReaction(message.id, emoji)
+          .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`)))
+      } else {
+        if (hasReachedReactionLimit(message, currentAccount?.did)) return
+        convo
+          .addReaction(message.id, emoji)
+          .catch(() =>
+            Toast.show(_(msg`Failed to add emoji reaction`), 'xmark'),
+          )
+      }
+    },
+    [_, convo, message, currentAccount?.did],
+  )
+
   const sender = convo.convo.members.find(
     member => member.did === message.sender.did,
   )
@@ -81,7 +106,10 @@ export let MessageContextMenu = ({
       <ContextMenu.Root>
         {isNative && (
           <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}>
-            <EmojiReactionPicker message={message} />
+            <EmojiReactionPicker
+              message={message}
+              onEmojiSelect={onEmojiSelect}
+            />
           </ContextMenu.AuxiliaryView>
         )}
 
@@ -156,4 +184,4 @@ export let MessageContextMenu = ({
     </>
   )
 }
-MessageContextMenu = React.memo(MessageContextMenu)
+MessageContextMenu = memo(MessageContextMenu)
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 675b6a5ee..60b0b5ed6 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -1,23 +1,36 @@
 import React, {useCallback, useMemo} from 'react'
-import {GestureResponderEvent, StyleProp, TextStyle, View} from 'react-native'
+import {
+  type GestureResponderEvent,
+  type StyleProp,
+  type TextStyle,
+  View,
+} from 'react-native'
+import Animated, {
+  LayoutAnimationConfig,
+  LinearTransition,
+  ZoomIn,
+  ZoomOut,
+} from 'react-native-reanimated'
 import {
   AppBskyEmbedRecord,
   ChatBskyConvoDefs,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {I18n} from '@lingui/core'
+import {type I18n} from '@lingui/core'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {ConvoItem} from '#/state/messages/convo/types'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {useConvoActive} from '#/state/messages/convo'
+import {type ConvoItem} from '#/state/messages/convo/types'
 import {useSession} from '#/state/session'
 import {TimeElapsed} from '#/view/com/util/TimeElapsed'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, native, useTheme} from '#/alf'
 import {isOnlyEmoji} from '#/alf/typography'
 import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
 import {InlineLinkText} from '#/components/Link'
+import {RichText} from '#/components/RichText'
 import {Text} from '#/components/Typography'
-import {RichText} from '../RichText'
 import {DateDivider} from './DateDivider'
 import {MessageItemEmbed} from './MessageItemEmbed'
 import {localDateString} from './util'
@@ -29,6 +42,8 @@ let MessageItem = ({
 }): React.ReactNode => {
   const t = useTheme()
   const {currentAccount} = useSession()
+  const {_} = useLingui()
+  const {convo} = useConvoActive()
 
   const {message, nextMessage, prevMessage} = item
   const isPending = item.type === 'pending-message'
@@ -134,6 +149,74 @@ let MessageItem = ({
           )}
         </ActionsWrapper>
 
+        <LayoutAnimationConfig skipEntering skipExiting>
+          {message.reactions && message.reactions.length > 0 && (
+            <View
+              style={[
+                isFromSelf ? a.align_end : a.align_start,
+                a.px_xs,
+                a.pb_2xs,
+              ]}>
+              <View
+                style={[
+                  a.flex_row,
+                  a.gap_2xs,
+                  a.py_xs,
+                  a.px_xs,
+                  a.justify_center,
+                  isFromSelf ? a.justify_end : a.justify_start,
+                  a.flex_wrap,
+                  a.pb_xs,
+                  t.atoms.bg,
+                  a.rounded_lg,
+                  a.border,
+                  t.atoms.border_contrast_low,
+                  {marginTop: -6},
+                ]}>
+                {message.reactions.map((reaction, _i, reactions) => {
+                  let label
+                  if (reaction.sender.did === currentAccount?.did) {
+                    label = _(msg`You reacted ${reaction.value}`)
+                  } else {
+                    const senderDid = reaction.sender.did
+                    const sender = convo.members.find(
+                      member => member.did === senderDid,
+                    )
+                    if (sender) {
+                      label = _(
+                        msg`${sanitizeDisplayName(
+                          sender.displayName || sender.handle,
+                        )} reacted ${reaction.value}`,
+                      )
+                    } else {
+                      label = _(msg`Someone reacted ${reaction.value}`)
+                    }
+                  }
+                  return (
+                    <Animated.View
+                      entering={native(ZoomIn.springify(200).delay(400))}
+                      exiting={
+                        reactions.length > 1 && native(ZoomOut.delay(200))
+                      }
+                      layout={native(LinearTransition.delay(300))}
+                      key={reaction.sender.did + reaction.value}
+                      style={[a.p_2xs]}
+                      accessible={true}
+                      accessibilityLabel={label}
+                      accessibilityHint={_(
+                        msg`Double tap or long press the message to add a reaction`,
+                      )}>
+                      <Text emoji style={[a.text_sm]}>
+                        {reaction.value}
+                      </Text>
+                    </Animated.View>
+                  )
+                })}
+              </View>
+            </View>
+          )}
+        </LayoutAnimationConfig>
+
         {isLastInGroup && (
           <MessageItemMetadata
             item={item}
diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts
index 7315f5fc9..2bcc9c3bd 100644
--- a/src/components/dms/util.ts
+++ b/src/components/dms/util.ts
@@ -1,4 +1,7 @@
-import * as bsky from '#/types/bsky'
+import {type ChatBskyConvoDefs} from '@atproto/api'
+
+import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
+import type * as bsky from '#/types/bsky'
 
 export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
   switch (profile.associated?.chat?.allowIncoming) {
@@ -25,3 +28,29 @@ export function localDateString(date: Date) {
   // not padding with 0s because it's not necessary, it's just used for comparison
   return `${yyyy}-${mm}-${dd}`
 }
+
+export function hasAlreadyReacted(
+  message: ChatBskyConvoDefs.MessageView,
+  myDid: string | undefined,
+  emoji: string,
+): boolean {
+  if (!message.reactions) {
+    return false
+  }
+  return !!message.reactions.find(
+    reaction => reaction.value === emoji && reaction.sender.did === myDid,
+  )
+}
+
+export function hasReachedReactionLimit(
+  message: ChatBskyConvoDefs.MessageView,
+  myDid: string | undefined,
+): boolean {
+  if (!message.reactions) {
+    return false
+  }
+  const myReactions = message.reactions.filter(
+    reaction => reaction.sender.did === myDid,
+  )
+  return myReactions.length >= EMOJI_REACTION_LIMIT
+}