about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/alf/atoms.ts13
-rw-r--r--src/alf/tokens.ts1
-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
-rw-r--r--src/lib/constants.ts6
-rw-r--r--src/screens/Messages/components/ChatListItem.tsx57
-rw-r--r--src/state/messages/convo/agent.ts192
-rw-r--r--src/state/messages/convo/index.tsx14
-rw-r--r--src/state/messages/convo/types.ts26
-rw-r--r--src/state/queries/messages/list-conversations.tsx67
15 files changed, 627 insertions, 115 deletions
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 69ac0c057..5a3e6d675 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,4 +1,9 @@
-import {Platform, StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {
+  Platform,
+  type StyleProp,
+  StyleSheet,
+  type ViewStyle,
+} from 'react-native'
 
 import * as tokens from '#/alf/tokens'
 import {ios, native, platform, web} from '#/alf/util/platform'
@@ -126,6 +131,9 @@ export const atoms = {
   rounded_md: {
     borderRadius: tokens.borderRadius.md,
   },
+  rounded_lg: {
+    borderRadius: tokens.borderRadius.lg,
+  },
   rounded_full: {
     borderRadius: tokens.borderRadius.full,
   },
@@ -358,6 +366,9 @@ export const atoms = {
   border_r: {
     borderRightWidth: StyleSheet.hairlineWidth,
   },
+  border_transparent: {
+    borderColor: 'transparent',
+  },
   curve_circular: ios({
     borderCurve: 'circular',
   }),
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 1d99e5e57..576aafe95 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -44,6 +44,7 @@ export const borderRadius = {
   xs: 4,
   sm: 8,
   md: 12,
+  lg: 16,
   full: 999,
 } as const
 
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
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index a105de050..fe84f41b2 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,5 +1,5 @@
-import {Insets, Platform} from 'react-native'
-import {AppBskyActorDefs} from '@atproto/api'
+import {type Insets, Platform} from 'react-native'
+import {type AppBskyActorDefs} from '@atproto/api'
 
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
@@ -190,3 +190,5 @@ export const SUPPORTED_MIME_TYPES = [
 ] as const
 
 export type SupportedMimeTypes = (typeof SUPPORTED_MIME_TYPES)[number]
+
+export const EMOJI_REACTION_LIMIT = 5
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 96e010b8f..d8e4b975c 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -1,10 +1,10 @@
 import React, {useCallback, useMemo, useState} from 'react'
-import {GestureResponderEvent, View} from 'react-native'
+import {type GestureResponderEvent, View} from 'react-native'
 import {
   AppBskyEmbedRecord,
   ChatBskyConvoDefs,
   moderateProfile,
-  ModerationOpts,
+  type ModerationOpts,
 } from '@atproto/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -43,7 +43,7 @@ import {Link} from '#/components/Link'
 import {useMenuControl} from '#/components/Menu'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
 import {Text} from '#/components/Typography'
-import * as bsky from '#/types/bsky'
+import type * as bsky from '#/types/bsky'
 
 export let ChatListItem = ({
   convo,
@@ -189,13 +189,62 @@ function ChatListItemReady({
           ? _(msg`Conversation deleted`)
           : _(msg`Message deleted`)
       }
+      if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastMessage)) {
+        const isFromMe =
+          convo.lastMessage.reaction.sender.did === currentAccount?.did
+        const lastMessageText = convo.lastMessage.message.text
+        const fallbackMessage = _(
+          msg({
+            message: 'a message',
+            comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
+          }),
+        )
+
+        if (isFromMe) {
+          lastMessage = _(
+            msg`You reacted ${convo.lastMessage.reaction.value} to ${
+              lastMessageText
+                ? `"${convo.lastMessage.message.text}"`
+                : fallbackMessage
+            }`,
+          )
+        } else {
+          const senderDid = convo.lastMessage.reaction.sender.did
+          const sender = convo.members.find(member => member.did === senderDid)
+          if (sender) {
+            lastMessage = _(
+              msg`${sanitizeDisplayName(
+                sender.displayName || sender.handle,
+              )} reacted ${convo.lastMessage.reaction.value} to ${
+                lastMessageText
+                  ? `"${convo.lastMessage.message.text}"`
+                  : fallbackMessage
+              }`,
+            )
+          } else {
+            lastMessage = _(
+              msg`Someone reacted ${convo.lastMessage.reaction.value} to ${
+                lastMessageText
+                  ? `"${convo.lastMessage.message.text}"`
+                  : fallbackMessage
+              }`,
+            )
+          }
+        }
+      }
 
       return {
         lastMessage,
         lastMessageSentAt,
         latestReportableMessage,
       }
-    }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
+    }, [
+      _,
+      convo.lastMessage,
+      currentAccount?.did,
+      isDeletedAccount,
+      convo.members,
+    ])
 
   const [showActions, setShowActions] = useState(false)
 
diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts
index f6a8d6dc4..909213975 100644
--- a/src/state/messages/convo/agent.ts
+++ b/src/state/messages/convo/agent.ts
@@ -1,9 +1,9 @@
 import {
-  BskyAgent,
-  ChatBskyActorDefs,
+  type AtpAgent,
+  type ChatBskyActorDefs,
   ChatBskyConvoDefs,
-  ChatBskyConvoGetLog,
-  ChatBskyConvoSendMessage,
+  type ChatBskyConvoGetLog,
+  type ChatBskyConvoSendMessage,
 } from '@atproto/api'
 import {XRPCError} from '@atproto/xrpc'
 import EventEmitter from 'eventemitter3'
@@ -19,19 +19,19 @@ import {
   NETWORK_FAILURE_STATUSES,
 } from '#/state/messages/convo/const'
 import {
-  ConvoDispatch,
+  type ConvoDispatch,
   ConvoDispatchEvent,
-  ConvoError,
+  type ConvoError,
   ConvoErrorCode,
-  ConvoEvent,
-  ConvoItem,
+  type ConvoEvent,
+  type ConvoItem,
   ConvoItemError,
-  ConvoParams,
-  ConvoState,
+  type ConvoParams,
+  type ConvoState,
   ConvoStatus,
 } from '#/state/messages/convo/types'
-import {MessagesEventBus} from '#/state/messages/events/agent'
-import {MessagesEventBusError} from '#/state/messages/events/types'
+import {type MessagesEventBus} from '#/state/messages/events/agent'
+import {type MessagesEventBusError} from '#/state/messages/events/types'
 import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
 
 const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -50,7 +50,7 @@ export function isConvoItemMessage(
 export class Convo {
   private id: string
 
-  private agent: BskyAgent
+  private agent: AtpAgent
   private events: MessagesEventBus
   private senderUserDid: string
 
@@ -106,6 +106,8 @@ export class Convo {
     this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
     this.onFirehoseError = this.onFirehoseError.bind(this)
     this.markConvoAccepted = this.markConvoAccepted.bind(this)
+    this.addReaction = this.addReaction.bind(this)
+    this.removeReaction = this.removeReaction.bind(this)
   }
 
   private commit() {
@@ -147,6 +149,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
       case ConvoStatus.Disabled:
@@ -165,6 +169,8 @@ export class Convo {
           sendMessage: this.sendMessage,
           fetchMessageHistory: this.fetchMessageHistory,
           markConvoAccepted: this.markConvoAccepted,
+          addReaction: this.addReaction,
+          removeReaction: this.removeReaction,
         }
       }
       case ConvoStatus.Error: {
@@ -180,6 +186,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
       default: {
@@ -195,6 +203,8 @@ export class Convo {
           sendMessage: undefined,
           fetchMessageHistory: undefined,
           markConvoAccepted: undefined,
+          addReaction: undefined,
+          removeReaction: undefined,
         }
       }
     }
@@ -760,6 +770,22 @@ export class Convo {
               this.deletedMessages.delete(ev.message.id)
               needsCommit = true
             }
+          } else if (
+            (ChatBskyConvoDefs.isLogAddReaction(ev) ||
+              ChatBskyConvoDefs.isLogRemoveReaction(ev)) &&
+            ChatBskyConvoDefs.isMessageView(ev.message)
+          ) {
+            /*
+             * Update if we have this in state - replace message wholesale. If we don't, don't worry about it.
+             */
+            if (this.pastMessages.has(ev.message.id)) {
+              this.pastMessages.set(ev.message.id, ev.message)
+              needsCommit = true
+            }
+            if (this.newMessages.has(ev.message.id)) {
+              this.newMessages.set(ev.message.id, ev.message)
+              needsCommit = true
+            }
           }
         }
       }
@@ -1141,4 +1167,144 @@ export class Convo {
         return item
       })
   }
+
+  /**
+   * Add an emoji reaction to a message
+   *
+   * @param messageId - the id of the message to add the reaction to
+   * @param emoji - must be one grapheme
+   */
+  async addReaction(messageId: string, emoji: string) {
+    const optimisticReaction = {
+      value: emoji,
+      sender: {did: this.senderUserDid},
+      createdAt: new Date().toISOString(),
+    }
+    let restore: null | (() => void) = null
+    if (this.pastMessages.has(messageId)) {
+      const prevMessage = this.pastMessages.get(messageId)
+      if (
+        ChatBskyConvoDefs.isMessageView(prevMessage) &&
+        // skip optimistic update if reaction already exists
+        !prevMessage.reactions?.find(
+          reaction =>
+            reaction.sender.did === this.senderUserDid &&
+            reaction.value === emoji,
+        )
+      ) {
+        if (prevMessage.reactions) {
+          if (
+            prevMessage.reactions.filter(
+              reaction => reaction.sender.did === this.senderUserDid,
+            ).length >= 5
+          ) {
+            throw new Error('Maximum reactions reached')
+          }
+        }
+        this.pastMessages.set(messageId, {
+          ...prevMessage,
+          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
+        })
+        this.commit()
+        restore = () => {
+          this.pastMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    } else if (this.newMessages.has(messageId)) {
+      const prevMessage = this.newMessages.get(messageId)
+      if (
+        ChatBskyConvoDefs.isMessageView(prevMessage) &&
+        !prevMessage.reactions?.find(reaction => reaction.value === emoji)
+      ) {
+        if (prevMessage.reactions && prevMessage.reactions.length >= 5)
+          throw new Error('Maximum reactions reached')
+        this.newMessages.set(messageId, {
+          ...prevMessage,
+          reactions: [...(prevMessage.reactions ?? []), optimisticReaction],
+        })
+        this.commit()
+        restore = () => {
+          this.newMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    }
+
+    try {
+      logger.info(`Adding reaction ${emoji} to message ${messageId}`)
+      const {data} = await this.agent.chat.bsky.convo.addReaction(
+        {messageId, value: emoji, convoId: this.convoId},
+        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
+      )
+      if (ChatBskyConvoDefs.isMessageView(data.message)) {
+        if (this.pastMessages.has(messageId)) {
+          this.pastMessages.set(messageId, data.message)
+          this.commit()
+        } else if (this.newMessages.has(messageId)) {
+          this.newMessages.set(messageId, data.message)
+          this.commit()
+        }
+      }
+    } catch (error) {
+      if (restore) restore()
+      throw error
+    }
+  }
+
+  /*
+   * Remove a reaction from a message.
+   *
+   * @param messageId - The ID of the message to remove the reaction from.
+   * @param emoji - The emoji to remove.
+   */
+  async removeReaction(messageId: string, emoji: string) {
+    let restore: null | (() => void) = null
+    if (this.pastMessages.has(messageId)) {
+      const prevMessage = this.pastMessages.get(messageId)
+      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
+        this.pastMessages.set(messageId, {
+          ...prevMessage,
+          reactions: prevMessage.reactions?.filter(
+            reaction =>
+              reaction.value !== emoji ||
+              reaction.sender.did !== this.senderUserDid,
+          ),
+        })
+        this.commit()
+        restore = () => {
+          this.pastMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    } else if (this.newMessages.has(messageId)) {
+      const prevMessage = this.newMessages.get(messageId)
+      if (ChatBskyConvoDefs.isMessageView(prevMessage)) {
+        this.newMessages.set(messageId, {
+          ...prevMessage,
+          reactions: prevMessage.reactions?.filter(
+            reaction =>
+              reaction.value !== emoji ||
+              reaction.sender.did !== this.senderUserDid,
+          ),
+        })
+        this.commit()
+        restore = () => {
+          this.newMessages.set(messageId, prevMessage)
+          this.commit()
+        }
+      }
+    }
+
+    try {
+      logger.info(`Removing reaction ${emoji} from message ${messageId}`)
+      await this.agent.chat.bsky.convo.removeReaction(
+        {messageId, value: emoji, convoId: this.convoId},
+        {encoding: 'application/json', headers: DM_SERVICE_HEADERS},
+      )
+    } catch (error) {
+      if (restore) restore()
+      throw error
+    }
+  }
 }
diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx
index f004566e8..a53f08900 100644
--- a/src/state/messages/convo/index.tsx
+++ b/src/state/messages/convo/index.tsx
@@ -1,17 +1,17 @@
 import React, {useContext, useState, useSyncExternalStore} from 'react'
-import {ChatBskyConvoDefs} from '@atproto/api'
+import {type ChatBskyConvoDefs} from '@atproto/api'
 import {useFocusEffect} from '@react-navigation/native'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useAppState} from '#/lib/hooks/useAppState'
 import {Convo} from '#/state/messages/convo/agent'
 import {
-  ConvoParams,
-  ConvoState,
-  ConvoStateBackgrounded,
-  ConvoStateDisabled,
-  ConvoStateReady,
-  ConvoStateSuspended,
+  type ConvoParams,
+  type ConvoState,
+  type ConvoStateBackgrounded,
+  type ConvoStateDisabled,
+  type ConvoStateReady,
+  type ConvoStateSuspended,
 } from '#/state/messages/convo/types'
 import {isConvoActive} from '#/state/messages/convo/util'
 import {useMessagesEventBus} from '#/state/messages/events'
diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts
index 83499de2e..705387793 100644
--- a/src/state/messages/convo/types.ts
+++ b/src/state/messages/convo/types.ts
@@ -1,11 +1,11 @@
 import {
-  BskyAgent,
-  ChatBskyActorDefs,
-  ChatBskyConvoDefs,
-  ChatBskyConvoSendMessage,
+  type BskyAgent,
+  type ChatBskyActorDefs,
+  type ChatBskyConvoDefs,
+  type ChatBskyConvoSendMessage,
 } from '@atproto/api'
 
-import {MessagesEventBus} from '#/state/messages/events/agent'
+import {type MessagesEventBus} from '#/state/messages/events/agent'
 
 export type ConvoParams = {
   convoId: string
@@ -142,6 +142,8 @@ type SendMessage = (
 ) => void
 type FetchMessageHistory = () => Promise<void>
 type MarkConvoAccepted = () => void
+type AddReaction = (messageId: string, reaction: string) => Promise<void>
+type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
 
 export type ConvoStateUninitialized = {
   status: ConvoStatus.Uninitialized
@@ -155,6 +157,8 @@ export type ConvoStateUninitialized = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateInitializing = {
   status: ConvoStatus.Initializing
@@ -168,6 +172,8 @@ export type ConvoStateInitializing = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateReady = {
   status: ConvoStatus.Ready
@@ -181,6 +187,8 @@ export type ConvoStateReady = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateBackgrounded = {
   status: ConvoStatus.Backgrounded
@@ -194,6 +202,8 @@ export type ConvoStateBackgrounded = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateSuspended = {
   status: ConvoStatus.Suspended
@@ -207,6 +217,8 @@ export type ConvoStateSuspended = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoStateError = {
   status: ConvoStatus.Error
@@ -220,6 +232,8 @@ export type ConvoStateError = {
   sendMessage: undefined
   fetchMessageHistory: undefined
   markConvoAccepted: undefined
+  addReaction: undefined
+  removeReaction: undefined
 }
 export type ConvoStateDisabled = {
   status: ConvoStatus.Disabled
@@ -233,6 +247,8 @@ export type ConvoStateDisabled = {
   sendMessage: SendMessage
   fetchMessageHistory: FetchMessageHistory
   markConvoAccepted: MarkConvoAccepted
+  addReaction: AddReaction
+  removeReaction: RemoveReaction
 }
 export type ConvoState =
   | ConvoStateUninitialized
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index f5fce6347..066c25e21 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -1,19 +1,13 @@
-import React, {
-  createContext,
-  useCallback,
-  useContext,
-  useEffect,
-  useMemo,
-} from 'react'
+import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
 import {
   ChatBskyConvoDefs,
-  ChatBskyConvoListConvos,
+  type ChatBskyConvoListConvos,
   moderateProfile,
-  ModerationOpts,
+  type ModerationOpts,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
+  type InfiniteData,
+  type QueryClient,
   useInfiniteQuery,
   useQueryClient,
 } from '@tanstack/react-query'
@@ -316,6 +310,57 @@ export function ListConvosProviderInner({
                   rev: logRef.rev,
                 })),
             )
+          } else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
+            const logRef: ChatBskyConvoDefs.LogAddReaction = log
+            queryClient.setQueriesData(
+              {queryKey: [RQKEY_ROOT]},
+              (old?: ConvoListQueryData) =>
+                optimisticUpdate(logRef.convoId, old, convo => ({
+                  ...convo,
+                  lastMessage: {
+                    $type: 'chat.bsky.convo.defs#messageAndReactionView',
+                    reaction: logRef.reaction,
+                    message: logRef.message,
+                  },
+                  rev: logRef.rev,
+                })),
+            )
+          } else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
+            if (ChatBskyConvoDefs.isMessageView(log.message)) {
+              for (const [_queryKey, queryData] of queryClient.getQueriesData<
+                InfiniteData<ChatBskyConvoListConvos.OutputSchema>
+              >({
+                queryKey: [RQKEY_ROOT],
+              })) {
+                if (!queryData?.pages) {
+                  continue
+                }
+
+                for (const page of queryData.pages) {
+                  for (const convo of page.convos) {
+                    if (
+                      // if the convo is the same
+                      log.convoId === convo.id &&
+                      ChatBskyConvoDefs.isMessageAndReactionView(
+                        convo.lastMessage,
+                      ) &&
+                      ChatBskyConvoDefs.isMessageView(
+                        convo.lastMessage.message,
+                      ) &&
+                      // ...and the message is the same
+                      convo.lastMessage.message.id === log.message.id &&
+                      // ...and the reaction is the same
+                      convo.lastMessage.reaction.sender.did ===
+                        log.reaction.sender.did &&
+                      convo.lastMessage.reaction.value === log.reaction.value
+                    ) {
+                      // refetch, because we don't know what the last message is now
+                      debouncedRefetch()
+                    }
+                  }
+                }
+              }
+            }
           }
         }
       },