diff --git a/README.md b/README.md
index 4f4023bbe..ed8766c22 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@
- ⌨️ **Keyboard Handling** - Smart keyboard avoidance for all platforms
- 💬 **System Messages** - Display system notifications in chat
- ⚡ **Quick Replies** - Bot-style quick reply buttons
+- 😀 **Emoji Reactions** - Long-press to react, with reaction pills and an optional full emoji browser
- ✍️ **Typing Indicator** - Show when users are typing
- ✅ **Message Status** - Tick indicators for sent/delivered/read states
- ⬇️ **Scroll to Bottom** - Quick navigation button
@@ -662,6 +663,77 @@ const [isScrolledUp, setIsScrolledUp] = useState(false)
/>
```
+### Emoji Reactions
+
+Long-press a message to open a quick emoji picker; selected reactions render as pills below the bubble and toggle on tap. The core ships a lightweight quick picker (built on `react-native-gesture-handler` and `react-native-reanimated`, no extra dependencies). A full emoji browser is optional and demonstrated in the example app via the `renderReactionPicker` override.
+
+
+
+
+
+
+
+
+
+Store reactions on each message as a `reactions` array, then enable the feature and handle the toggle. Reaction state is owned by you, so it works with any backend:
+
+```tsx
+interface IChatMessage extends IMessage {
+ reactions?: MessageReaction[] // { emoji: string, userIds: (string | number)[] }[]
+}
+
+const CURRENT_USER_ID = 1
+
+const handleReactionPress = useCallback((message: IChatMessage, emoji: string) => {
+ setMessages(prev =>
+ prev.map(m => {
+ if (m._id !== message._id)
+ return m
+
+ const existing = (m.reactions ?? []).find(r => r.emoji === emoji)
+ if (!existing)
+ return { ...m, reactions: [...(m.reactions ?? []), { emoji, userIds: [CURRENT_USER_ID] }] }
+
+ const userIds = existing.userIds.includes(CURRENT_USER_ID)
+ ? existing.userIds.filter(id => id !== CURRENT_USER_ID)
+ : [...existing.userIds, CURRENT_USER_ID]
+
+ return {
+ ...m,
+ reactions: userIds.length === 0
+ ? (m.reactions ?? []).filter(r => r.emoji !== emoji)
+ : (m.reactions ?? []).map(r => (r.emoji === emoji ? { ...r, userIds } : r)),
+ }
+ })
+ )
+}, [])
+
+ ,
+ }}
+/>
+```
+
+#### Reactions Props (Grouped)
+
+The `reactions` prop accepts:
+
+- **`isEnabled`** _(Bool)_ - Enable emoji reactions (default `false`)
+- **`emojis`** _(String[])_ - Emojis shown in the quick picker (default `['👍', '❤️', '😂', '😮', '😢', '👎']`)
+- **`onReactionPress`** _(Function)_ - `(message, emoji) => void` called when an emoji is selected or a pill is tapped. Toggle logic is left to you
+- **`renderReactions`** _(Function)_ - Override the reactions-display component rendered below the bubble
+- **`renderReactionPicker`** _(Function)_ - Override the picker shown on long-press (use for a full emoji browser)
+- **`containerStyle`**, **`reactionStyle`**, **`reactionActiveStyle`**, **`reactionTextStyle`**, **`reactionCountStyle`** - Styles for the reaction pills
+- **`pickerContainerStyle`**, **`pickerEmojiStyle`** - Styles for the quick picker
+
---
## 📱 Platform Notes
diff --git a/example/app/(tabs)/explore.tsx b/example/app/(tabs)/explore.tsx
index f1ec6bab3..f0f6d291e 100644
--- a/example/app/(tabs)/explore.tsx
+++ b/example/app/(tabs)/explore.tsx
@@ -8,7 +8,7 @@ import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useThemeColor } from '@/hooks/use-theme-color'
-type ChatExample = 'basic' | 'customized-rendering' | 'slack' | 'links' | 'reply' | 'day-animated'
+type ChatExample = 'basic' | 'customized-rendering' | 'slack' | 'links' | 'reply' | 'day-animated' | 'reactions'
const examples: Array<{ id: ChatExample, title: string, description: string }> = [
{ id: 'basic', title: 'Basic Example', description: 'Basic chat with keyboard logging for testing' },
@@ -17,6 +17,7 @@ const examples: Array<{ id: ChatExample, title: string, description: string }> =
{ id: 'slack', title: 'Slack Style', description: 'Slack-like message styling' },
{ id: 'reply', title: 'Reply Example', description: 'Example demonstrating reply functionality' },
{ id: 'day-animated', title: 'Day Animated', description: 'Multi-day chat with Load earlier for testing the animated day header' },
+ { id: 'reactions', title: 'Reactions', description: 'Long-press a message to react with emojis, with a full emoji browser' },
]
export default function ExploreScreen () {
diff --git a/example/app/chat/_layout.tsx b/example/app/chat/_layout.tsx
index 489a85520..c3b5e31d0 100644
--- a/example/app/chat/_layout.tsx
+++ b/example/app/chat/_layout.tsx
@@ -44,6 +44,10 @@ export default function ChatLayout () {
name='day-animated'
options={{ title: 'Day Animated' }}
/>
+
)
}
diff --git a/example/app/chat/reactions.tsx b/example/app/chat/reactions.tsx
new file mode 100644
index 000000000..42c471022
--- /dev/null
+++ b/example/app/chat/reactions.tsx
@@ -0,0 +1,3 @@
+import ReactionsExample from '@/components/chat-examples/ReactionsExample'
+
+export default ReactionsExample
diff --git a/example/components/chat-examples/ReactionsExample.tsx b/example/components/chat-examples/ReactionsExample.tsx
new file mode 100644
index 000000000..d2987d725
--- /dev/null
+++ b/example/components/chat-examples/ReactionsExample.tsx
@@ -0,0 +1,130 @@
+import React, { useCallback, useMemo, useState } from 'react'
+import { StyleSheet, View, useColorScheme } from 'react-native'
+import { GiftedChat, IMessage, MessageReaction, ReactionPickerProps } from 'react-native-gifted-chat'
+
+import messagesData from '../../example-expo/data/messages'
+import { useKeyboardVerticalOffset } from '../../hooks/useKeyboardVerticalOffset'
+import { getColorSchemeStyle } from '../../utils/styleUtils'
+import { EmojiReactionPicker } from './reactions/EmojiReactionPicker'
+
+export interface IChatMessage extends IMessage {
+ reactions?: MessageReaction[]
+}
+
+const CURRENT_USER_ID = 1
+
+export default function ReactionsExample () {
+ const [messages, setMessages] = useState(messagesData)
+ const colorScheme = useColorScheme()
+ const keyboardVerticalOffset = useKeyboardVerticalOffset()
+ const isDark = colorScheme === 'dark'
+
+ const user = useMemo(() => ({
+ _id: CURRENT_USER_ID,
+ name: 'Developer',
+ }), [])
+
+ const onSend = useCallback((newMessages: IChatMessage[] = []) => {
+ setMessages(previousMessages =>
+ GiftedChat.append(previousMessages, newMessages)
+ )
+ }, [])
+
+ // Toggle the current user's reaction for the given emoji on a message.
+ const handleReactionPress = useCallback((message: IChatMessage, emoji: string) => {
+ setMessages(previousMessages =>
+ previousMessages.map(m => {
+ if (m._id !== message._id)
+ return m
+
+ const existing = (m.reactions ?? []).find(r => r.emoji === emoji)
+ if (existing) {
+ const newUserIds = existing.userIds.includes(CURRENT_USER_ID)
+ ? existing.userIds.filter(id => id !== CURRENT_USER_ID)
+ : [...existing.userIds, CURRENT_USER_ID]
+
+ return {
+ ...m,
+ reactions: newUserIds.length === 0
+ ? (m.reactions ?? []).filter(r => r.emoji !== emoji)
+ : (m.reactions ?? []).map(r =>
+ r.emoji === emoji ? { ...r, userIds: newUserIds } : r
+ ),
+ }
+ }
+
+ return {
+ ...m,
+ reactions: [...(m.reactions ?? []), { emoji, userIds: [CURRENT_USER_ID] }],
+ }
+ })
+ )
+ }, [])
+
+ // Provide a richer picker (quick bar + full emoji browser) via the override.
+ const renderReactionPicker = useCallback(
+ (pickerProps: ReactionPickerProps) => (
+
+ ),
+ [isDark]
+ )
+
+ return (
+
+
+ messages={messages}
+ onSend={onSend}
+ user={user}
+ messagesContainerStyle={getColorSchemeStyle(styles, 'messagesContainer', colorScheme)}
+ textInputProps={{
+ style: getColorSchemeStyle(styles, 'composer', colorScheme),
+ }}
+ keyboardAvoidingViewProps={{ keyboardVerticalOffset }}
+ reactions={{
+ isEnabled: true,
+ onReactionPress: handleReactionPress,
+ renderReactionPicker,
+ }}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#fff',
+ },
+ container_dark: {
+ backgroundColor: '#000',
+ },
+ messagesContainer_dark: {
+ backgroundColor: '#000',
+ },
+ composer_dark: {
+ backgroundColor: '#1a1a1a',
+ color: '#fff',
+ },
+})
diff --git a/example/components/chat-examples/reactions/EmojiReactionPicker.tsx b/example/components/chat-examples/reactions/EmojiReactionPicker.tsx
new file mode 100644
index 000000000..cc222d8cd
--- /dev/null
+++ b/example/components/chat-examples/reactions/EmojiReactionPicker.tsx
@@ -0,0 +1,249 @@
+import React, { useCallback, useRef } from 'react'
+import {
+ Dimensions,
+ Modal,
+ Pressable,
+ StyleProp,
+ StyleSheet,
+ Text,
+ TextStyle,
+ View,
+ ViewStyle,
+} from 'react-native'
+import { GestureHandlerRootView } from 'react-native-gesture-handler'
+import { IMessage, ReactionPickerProps } from 'react-native-gifted-chat'
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated'
+
+import {
+ EmojiPickerSearchBarProps,
+ FullEmojiPicker,
+ FullEmojiPickerRef,
+ FullEmojiPickerTheme,
+} from './FullEmojiPicker'
+
+const PICKER_HEIGHT = 54
+const EMOJI_BUTTON_SIZE = 46
+const PICKER_PADDING_H = 8
+const PICKER_VERTICAL_OFFSET = 8
+const QUICK_PICKER_ANIMATE_DURATION = 200
+
+/**
+ * Example picker that extends the built-in quick picker with a "+" button
+ * opening a full emoji browser (react-native-emoji-chooser). Wire it through
+ * `reactions.renderReactionPicker`. This lives in the example - the core
+ * library ships only the lightweight quick picker.
+ */
+export interface EmojiReactionPickerProps
+ extends ReactionPickerProps {
+ pickerContainerStyle?: StyleProp
+ pickerEmojiStyle?: StyleProp
+ isFullPickerEnabled?: boolean
+ mode?: 'light' | 'dark'
+ fullPickerLang?: string
+ fullPickerColumnCount?: number
+ fullPickerTheme?: FullEmojiPickerTheme
+ fullPickerSearchBarProps?: EmojiPickerSearchBarProps
+}
+
+export const EmojiReactionPicker = (
+ props: EmojiReactionPickerProps
+): React.ReactElement | null => {
+ const {
+ visible,
+ emojis,
+ onSelect,
+ onDismiss,
+ position,
+ pageX = 0,
+ pageY = 0,
+ bubbleWidth = 0,
+ bubbleHeight = 0,
+ pickerContainerStyle,
+ pickerEmojiStyle,
+ isFullPickerEnabled,
+ mode = 'light',
+ fullPickerLang,
+ fullPickerColumnCount,
+ fullPickerTheme,
+ fullPickerSearchBarProps,
+ } = props
+
+ const { width: screenWidth } = Dimensions.get('window')
+
+ const fullPickerRef = useRef(null)
+
+ const pickerScale = useSharedValue(1)
+
+ const quickPickerStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: pickerScale.value }],
+ }))
+
+ const pickerWidth = emojis.length * EMOJI_BUTTON_SIZE + PICKER_PADDING_H * 2
+ + (isFullPickerEnabled ? EMOJI_BUTTON_SIZE : 0)
+
+ const showAbove = pageY >= PICKER_HEIGHT + PICKER_VERTICAL_OFFSET
+ const pickerTop = showAbove
+ ? pageY - PICKER_HEIGHT - PICKER_VERTICAL_OFFSET
+ : pageY + bubbleHeight + PICKER_VERTICAL_OFFSET
+
+ let pickerLeft: number
+ if (position === 'right')
+ pickerLeft = pageX + bubbleWidth - pickerWidth
+ else
+ pickerLeft = pageX
+
+ pickerLeft = Math.max(8, Math.min(pickerLeft, screenWidth - pickerWidth - 8))
+
+ const handleSelect = useCallback(
+ (emoji: string) => {
+ onSelect(emoji)
+ onDismiss()
+ },
+ [onSelect, onDismiss]
+ )
+
+ const openFullPicker = useCallback(() => {
+ fullPickerRef.current?.expand()
+ }, [])
+
+ const handleOpenFullPicker = useCallback(() => {
+ pickerScale.value = withTiming(
+ 0,
+ { duration: QUICK_PICKER_ANIMATE_DURATION },
+ finished => {
+ if (finished)
+ runOnJS(openFullPicker)()
+ }
+ )
+ }, [openFullPicker, pickerScale])
+
+ if (!visible)
+ return null
+
+ return (
+
+ {/* GestureHandlerRootView is required inside the Modal so the
+ FullEmojiPicker's pan-to-dismiss gesture is recognised. */}
+
+
+
+ {/* Quick-picker bar - scales away before the full picker opens */}
+
+ {emojis.map(emoji => (
+ handleSelect(emoji)}
+ style={({ pressed }) => [
+ styles.emojiButton,
+ pressed && styles.emojiButtonPressed,
+ ]}
+ >
+ {emoji}
+
+ ))}
+
+ {isFullPickerEnabled && (
+ <>
+
+ [
+ styles.emojiButton,
+ styles.plusButton,
+ pressed && styles.emojiButtonPressed,
+ ]}
+ >
+ {'+'}
+
+ >
+ )}
+
+
+ {isFullPickerEnabled && (
+
+ )}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ gestureRoot: {
+ flex: 1,
+ },
+ picker: {
+ position: 'absolute',
+ height: PICKER_HEIGHT,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#ffffff',
+ borderRadius: PICKER_HEIGHT / 2,
+ paddingHorizontal: PICKER_PADDING_H,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ emojiButton: {
+ width: EMOJI_BUTTON_SIZE,
+ height: EMOJI_BUTTON_SIZE,
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: EMOJI_BUTTON_SIZE / 2,
+ },
+ emojiButtonPressed: {
+ backgroundColor: 'rgba(0, 0, 0, 0.07)',
+ transform: [{ scale: 1.2 }],
+ },
+ emoji: {
+ fontSize: 26,
+ lineHeight: 32,
+ },
+ divider: {
+ width: 1,
+ height: 28,
+ backgroundColor: 'rgba(0, 0, 0, 0.1)',
+ marginHorizontal: 2,
+ },
+ plusButton: {
+ backgroundColor: 'rgba(0, 0, 0, 0.04)',
+ },
+ plusText: {
+ fontSize: 22,
+ lineHeight: 26,
+ color: '#374151',
+ fontWeight: '500',
+ },
+})
diff --git a/example/components/chat-examples/reactions/FullEmojiPicker.tsx b/example/components/chat-examples/reactions/FullEmojiPicker.tsx
new file mode 100644
index 000000000..f2b05ca8c
--- /dev/null
+++ b/example/components/chat-examples/reactions/FullEmojiPicker.tsx
@@ -0,0 +1,233 @@
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useState,
+} from 'react'
+import {
+ Dimensions,
+ Platform,
+ Pressable,
+ StyleProp,
+ StyleSheet,
+ TextStyle,
+ View,
+ ViewStyle,
+} from 'react-native'
+import EmojiPicker from 'react-native-emoji-chooser'
+import { Gesture, GestureDetector } from 'react-native-gesture-handler'
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated'
+
+const { height: WINDOW_HEIGHT, width: WINDOW_WIDTH } = Dimensions.get('window')
+const SHEET_HEIGHT = WINDOW_HEIGHT * 0.8
+const SAFE_BOTTOM_PADDING = Platform.OS === 'ios' ? 24 : 16
+const SWIPE_CLOSE_THRESHOLD = 80
+
+export interface EmojiPickerSearchBarProps {
+ placeholderTextColor?: string
+ [key: string]: unknown
+}
+
+export interface EmojiPickerThemeMode {
+ searchbar?: {
+ container?: StyleProp
+ textInput?: StyleProp
+ }
+ toolbar?: {
+ container?: StyleProp
+ }
+}
+
+export interface FullEmojiPickerTheme {
+ light?: EmojiPickerThemeMode
+ dark?: EmojiPickerThemeMode
+}
+
+/** Imperative handle so the parent picker can slide the sheet in/out */
+export interface FullEmojiPickerRef {
+ expand: () => void
+ close: () => void
+}
+
+export interface FullEmojiPickerProps {
+ onSelect: (emoji: string) => void
+ onClose: () => void
+ mode?: 'light' | 'dark'
+ lang?: string
+ columnCount?: number
+ theme?: FullEmojiPickerTheme
+ searchBarProps?: EmojiPickerSearchBarProps
+}
+
+export const FullEmojiPicker = forwardRef(
+ (props, ref) => {
+ const {
+ onSelect,
+ onClose,
+ mode = 'light',
+ lang = 'en',
+ columnCount = 6,
+ theme = {},
+ searchBarProps,
+ } = props
+
+ const [isMounted, setIsMounted] = useState(false)
+
+ const translateY = useSharedValue(SHEET_HEIGHT)
+ const backdropOpacity = useSharedValue(0)
+
+ const close = useCallback(() => {
+ translateY.value = withTiming(SHEET_HEIGHT, { duration: 250 })
+ backdropOpacity.value = withTiming(0, { duration: 250 }, finished => {
+ if (finished)
+ runOnJS(onClose)()
+ })
+ }, [backdropOpacity, onClose, translateY])
+
+ const expand = useCallback(() => {
+ setIsMounted(true)
+ }, [])
+
+ // Trigger the entrance animation once the component is mounted in the tree
+ useEffect(() => {
+ if (!isMounted)
+ return
+
+ translateY.value = withTiming(0, { duration: 300 })
+ backdropOpacity.value = withTiming(0.6, { duration: 300 })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isMounted])
+
+ useImperativeHandle(ref, () => ({ expand, close }), [expand, close])
+
+ // Swipe-to-dismiss gesture
+ const panGesture = Gesture.Pan()
+ .onUpdate(e => {
+ translateY.value = Math.max(0, e.translationY)
+ })
+ .onEnd(e => {
+ if (e.translationY > SWIPE_CLOSE_THRESHOLD)
+ runOnJS(close)()
+ else
+ translateY.value = withSpring(0, { damping: 22, stiffness: 110 })
+ })
+
+ const sheetStyle = useAnimatedStyle(() => ({
+ transform: [{ translateY: translateY.value }],
+ }))
+
+ const backdropStyle = useAnimatedStyle(() => ({
+ opacity: backdropOpacity.value,
+ }))
+
+ if (!isMounted)
+ return null
+
+ const lightOverrides = theme.light ?? {}
+ const darkOverrides = theme.dark ?? {}
+
+ const emojiPickerTheme: Record = {
+ light: {
+ searchbar: {
+ ...(lightOverrides.searchbar as Record),
+ placeholderColor: searchBarProps?.placeholderTextColor,
+ },
+ toolbar: {
+ container: {
+ paddingBottom: SAFE_BOTTOM_PADDING,
+ ...(lightOverrides.toolbar?.container as Record),
+ },
+ },
+ },
+ dark: {
+ searchbar: {
+ ...(darkOverrides.searchbar as Record),
+ placeholderColor: searchBarProps?.placeholderTextColor,
+ },
+ toolbar: {
+ container: {
+ paddingBottom: SAFE_BOTTOM_PADDING,
+ ...(darkOverrides.toolbar?.container as Record),
+ },
+ },
+ },
+ }
+
+ const bgColor = mode === 'dark' ? '#111827' : '#ffffff'
+ const handleColor = mode === 'dark' ? '#4b5563' : '#d1d5db'
+
+ return (
+ <>
+ {/* Semi-transparent backdrop - tap to dismiss */}
+
+
+
+
+ {/* Slide-up sheet */}
+
+ {/* Drag handle - pan gesture attached here */}
+
+
+
+
+
+
+ {
+ onSelect(emoji)
+ close()
+ }}
+ mode={mode}
+ lang={lang}
+ columnCount={columnCount}
+ theme={emojiPickerTheme}
+ searchBarProps={searchBarProps}
+ />
+
+ >
+ )
+ }
+)
+
+FullEmojiPicker.displayName = 'FullEmojiPicker'
+
+const styles = StyleSheet.create({
+ backdrop: {
+ backgroundColor: '#000000',
+ },
+ sheet: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ overflow: 'hidden',
+ },
+ handleContainer: {
+ width: '100%',
+ alignItems: 'center',
+ paddingVertical: 12,
+ },
+ handle: {
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ },
+})
diff --git a/example/package.json b/example/package.json
index 6502c031d..c85ca0474 100644
--- a/example/package.json
+++ b/example/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^15.1.1",
+ "@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.18.2",
"@react-navigation/elements": "^2.9.25",
"@react-navigation/native": "^7.3.3",
@@ -43,6 +44,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.85.3",
+ "react-native-emoji-chooser": "^0.1.11",
"react-native-gesture-handler": "~2.31.1",
"react-native-gifted-chat": "link:..",
"react-native-keyboard-controller": "^1.21.6",
@@ -51,6 +53,7 @@
"react-native-reanimated": "^4.3.1",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.25.2",
+ "react-native-svg": "15.15.4",
"react-native-web": "~0.21.2",
"react-native-worklets": "^0.9.2"
},
diff --git a/example/yarn.lock b/example/yarn.lock
index 2bdb0ebf0..3a91eeeca 100644
--- a/example/yarn.lock
+++ b/example/yarn.lock
@@ -1774,6 +1774,13 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz#c882e66497174d061f250e65251974b699c65b65"
integrity sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==
+"@react-native-async-storage/async-storage@2.2.0":
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad"
+ integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==
+ dependencies:
+ merge-options "^3.0.4"
+
"@react-native-masked-view/masked-view@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz#7064533a573e3539ec912f59c1f457371bf49dd9"
@@ -2848,6 +2855,11 @@ big-integer@1.6.x:
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85"
integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==
+boolbase@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+ integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
+
bplist-creator@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/bplist-creator/-/bplist-creator-0.1.0.tgz#018a2d1b587f769e379ef5519103730f8963ba1e"
@@ -3209,6 +3221,30 @@ css-in-js-utils@^3.1.0:
dependencies:
hyphenate-style-name "^1.0.3"
+css-select@^5.1.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e"
+ integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==
+ dependencies:
+ boolbase "^1.0.0"
+ css-what "^6.1.0"
+ domhandler "^5.0.2"
+ domutils "^3.0.1"
+ nth-check "^2.0.1"
+
+css-tree@^1.1.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d"
+ integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==
+ dependencies:
+ mdn-data "2.0.14"
+ source-map "^0.6.1"
+
+css-what@^6.1.0:
+ version "6.2.2"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea"
+ integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==
+
css.escape@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
@@ -3364,6 +3400,36 @@ dom-accessibility-api@^0.6.3:
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8"
integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==
+dom-serializer@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
+ integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
+ dependencies:
+ domelementtype "^2.3.0"
+ domhandler "^5.0.2"
+ entities "^4.2.0"
+
+domelementtype@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
+ integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
+
+domhandler@^5.0.2, domhandler@^5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
+ integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
+ dependencies:
+ domelementtype "^2.3.0"
+
+domutils@^3.0.1:
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
+ integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
+ dependencies:
+ dom-serializer "^2.0.0"
+ domelementtype "^2.3.0"
+ domhandler "^5.0.3"
+
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
@@ -3393,6 +3459,11 @@ emittery@^0.13.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
+emoji-datasource@^15.1.2:
+ version "15.1.2"
+ resolved "https://registry.yarnpkg.com/emoji-datasource/-/emoji-datasource-15.1.2.tgz#afec422adadeafbf59c4e346fe24a891900e257c"
+ integrity sha512-tXAqGsrDVhgCRpFePtaD9P4Z8Ro2SUQSL/4MIJBG0SxqQJaMslEbin8J53OaFwEBu6e7JxFaIF6s4mw9+8acAQ==
+
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -3413,6 +3484,11 @@ encodeurl@~2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
+entities@^4.2.0:
+ version "4.5.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
+ integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
+
error-ex@^1.3.1:
version "1.3.4"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414"
@@ -4470,6 +4546,11 @@ graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+grapheme-splitter@^1.0.4:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
+ integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
+
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe"
@@ -4842,6 +4923,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+is-plain-obj@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+ integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
is-regex@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22"
@@ -5684,6 +5770,11 @@ math-intrinsics@^1.1.0:
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
+mdn-data@2.0.14:
+ version "2.0.14"
+ resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
+ integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==
+
memoize-one@^5.0.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
@@ -5694,6 +5785,13 @@ memoize-one@^6.0.0:
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==
+merge-options@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.4.tgz#84709c2aa2a4b24c1981f66c179fe5565cc6dbb7"
+ integrity sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==
+ dependencies:
+ is-plain-obj "^2.1.0"
+
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -6090,6 +6188,13 @@ npm-run-path@^4.0.1:
dependencies:
path-key "^3.0.0"
+nth-check@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
+ integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
+ dependencies:
+ boolbase "^1.0.0"
+
nullthrows@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1"
@@ -6586,6 +6691,15 @@ react-native-drawer-layout@^4.2.2:
color "^4.2.3"
use-latest-callback "^0.2.4"
+react-native-emoji-chooser@^0.1.11:
+ version "0.1.11"
+ resolved "https://registry.yarnpkg.com/react-native-emoji-chooser/-/react-native-emoji-chooser-0.1.11.tgz#d54c05d276c363c3cec45d3cfee71dab37cb5d62"
+ integrity sha512-+mnhR+2M/L2Ddepj9ROAnUdDCz8bvRVxF9dHQvEPqDTmHJUYQ26OX98HDSv7uYPZx5ZQ8kZ5s6YYLgQYaAlQ1w==
+ dependencies:
+ emoji-datasource "^15.1.2"
+ grapheme-splitter "^1.0.4"
+ react-native-section-list-get-item-layout "^2.2.3"
+
react-native-gesture-handler@~2.31.1:
version "2.31.2"
resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.31.2.tgz#f1832592619db00d70b036f73cf48f54cb415d71"
@@ -6649,6 +6763,20 @@ react-native-screens@^4.25.2:
react-freeze "^1.0.0"
warn-once "^0.1.0"
+react-native-section-list-get-item-layout@^2.2.3:
+ version "2.2.3"
+ resolved "https://registry.yarnpkg.com/react-native-section-list-get-item-layout/-/react-native-section-list-get-item-layout-2.2.3.tgz#152a8efdcb3847dbcbeb04cd2d0de4312d3d2fcb"
+ integrity sha512-fzCW5SiYP6qCZyDHebaElHonIFr8NFrZK9JDkxFLnpxMJih4d+HQ4rHyOs0Z4Gb/FjyCVbRH7RtEnjeQ0XffMg==
+
+react-native-svg@15.15.4:
+ version "15.15.4"
+ resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.15.4.tgz#c029be85c20ead09c63dffa6b1ade06012f095ed"
+ integrity sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==
+ dependencies:
+ css-select "^5.1.0"
+ css-tree "^1.1.3"
+ warn-once "0.1.1"
+
react-native-web@~0.21.2:
version "0.21.2"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.21.2.tgz#0f6983dfea600d9cc1c66fda87ff9ca585eaa647"
@@ -7178,7 +7306,7 @@ source-map@^0.5.6:
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
-source-map@^0.6.0:
+source-map@^0.6.0, source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@@ -7781,7 +7909,7 @@ walker@^1.0.7, walker@^1.0.8:
dependencies:
makeerror "1.0.12"
-warn-once@^0.1.0:
+warn-once@0.1.1, warn-once@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/warn-once/-/warn-once-0.1.1.tgz#952088f4fb56896e73fd4e6a3767272a3fccce43"
integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==
diff --git a/media/reactions-emoji-browser.png b/media/reactions-emoji-browser.png
new file mode 100644
index 000000000..1ab9f8412
Binary files /dev/null and b/media/reactions-emoji-browser.png differ
diff --git a/media/reactions-picker.png b/media/reactions-picker.png
new file mode 100644
index 000000000..b04e6f77b
Binary files /dev/null and b/media/reactions-picker.png differ
diff --git a/media/reactions-pills.png b/media/reactions-pills.png
new file mode 100644
index 000000000..8ceef3932
Binary files /dev/null and b/media/reactions-pills.png differ
diff --git a/src/Bubble/index.tsx b/src/Bubble/index.tsx
index 4ac1d50ed..cf625b7c3 100644
--- a/src/Bubble/index.tsx
+++ b/src/Bubble/index.tsx
@@ -1,9 +1,17 @@
-import React, { useCallback, useMemo } from 'react'
+import React, { useCallback, useMemo, useRef, useState } from 'react'
import {
View,
Pressable,
Text } from 'react-native'
-
+import { Gesture, GestureDetector } from 'react-native-gesture-handler'
+import Animated, {
+ Easing,
+ ReduceMotion,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated'
import { MessageReply } from '../components/MessageReply'
import { useChatContext } from '../GiftedChatContext'
@@ -13,6 +21,7 @@ import { MessageText } from '../MessageText'
import { MessageVideo } from '../MessageVideo'
import { IMessage } from '../Models'
import { QuickReplies } from '../QuickReplies'
+import { DEFAULT_REACTION_EMOJIS, MessageReactions, ReactionPicker } from '../Reactions'
import { getStyleWithPosition } from '../styles'
import { Time } from '../Time'
import { isSameUser, isSameDay, renderComponentOrElement } from '../utils'
@@ -21,6 +30,18 @@ import { BubbleProps, RenderMessageTextProps } from './types'
export * from './types'
+interface PickerAnchor {
+ pageX: number
+ pageY: number
+ bubbleWidth: number
+ bubbleHeight: number
+}
+
+const SCALE_PRESSED = 0.85
+const SCALE_DURATION_IN = 400
+const SCALE_DURATION_OUT = 200
+const SCALE_EASING = Easing.inOut(Easing.quad)
+
export const Bubble = (props: BubbleProps): React.ReactElement => {
const {
currentMessage,
@@ -39,10 +60,29 @@ export const Bubble = (props: BubbleProps<
bottomContainerStyle,
onPressMessage: onPressMessageProp,
onLongPressMessage: onLongPressMessageProp,
+ reactions,
} = props
const context = useChatContext()
+ const bubbleContainerRef = useRef(null)
+
+ const [isPickerVisible, setIsPickerVisible] = useState(false)
+ const [pickerAnchor, setPickerAnchor] = useState({
+ pageX: 0,
+ pageY: 0,
+ bubbleWidth: 0,
+ bubbleHeight: 0,
+ })
+
+ // Scale shared value is declared unconditionally so hooks order stays stable
+ // whether or not reactions are enabled.
+ const messageScale = useSharedValue(1)
+
+ const bubbleScaleStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: messageScale.value }],
+ }))
+
const onPress = useCallback(() => {
onPressMessageProp?.(context, currentMessage)
}, [onPressMessageProp, context, currentMessage])
@@ -55,6 +95,55 @@ export const Bubble = (props: BubbleProps<
onLongPressMessageProp,
])
+ const measureBubble = useCallback(() => {
+ bubbleContainerRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
+ setPickerAnchor({ pageX, pageY, bubbleWidth: width, bubbleHeight: height })
+ })
+ }, [])
+
+ const tapGesture = useMemo(
+ () =>
+ Gesture.Tap()
+ .runOnJS(true)
+ .onEnd((_e, success) => {
+ if (success)
+ onPressMessageProp?.(context, currentMessage)
+ }),
+ [onPressMessageProp, context, currentMessage]
+ )
+
+ const longPressGesture = useMemo(
+ () =>
+ Gesture.LongPress()
+ .onBegin(() => {
+ messageScale.value = withTiming(SCALE_PRESSED, {
+ duration: SCALE_DURATION_IN,
+ easing: SCALE_EASING,
+ reduceMotion: ReduceMotion.System,
+ })
+ runOnJS(measureBubble)()
+ })
+ .onStart(() => {
+ runOnJS(setIsPickerVisible)(true)
+ })
+ .onFinalize(() => {
+ messageScale.value = withTiming(1, {
+ duration: SCALE_DURATION_OUT,
+ easing: SCALE_EASING,
+ reduceMotion: ReduceMotion.System,
+ })
+ }),
+ [messageScale, measureBubble]
+ )
+
+ // Exclusive composition: a long-press wins over the tap when held long
+ // enough; a quick lift lets the tap through. Both share the onBegin/onFinalize
+ // scale animation because onBegin always fires before either gesture wins.
+ const reactionsGesture = useMemo(
+ () => Gesture.Exclusive(longPressGesture, tapGesture),
+ [longPressGesture, tapGesture]
+ )
+
const styledBubbleToNext = useMemo(() => {
if (
currentMessage &&
@@ -370,34 +459,116 @@ export const Bubble = (props: BubbleProps<
props.isCustomViewBottom,
])
- return (
-
+ const renderBubbleBody = useCallback(() => (
+ <>
+ {renderBubbleContent()}
+ {renderUsername()}
+
+ {renderTime()}
+ {renderTicks()}
+
+
+ >
+ ), [
+ position,
+ bottomContainerStyle,
+ renderBubbleContent,
+ renderUsername,
+ renderTime,
+ renderTicks,
+ ])
+
+ const wrapperStyleList = useMemo(() => [
+ getStyleWithPosition(styles, 'wrapper', position),
+ styledBubbleToNext,
+ styledBubbleToPrevious,
+ wrapperStyle?.[position],
+ ], [position, styledBubbleToNext, styledBubbleToPrevious, wrapperStyle])
+
+ const renderReactionsDisplay = useCallback(() => {
+ const currentReactions = currentMessage?.reactions
+ if (!currentReactions || currentReactions.length === 0)
+ return null
+
+ const displayProps = {
+ message: currentMessage,
+ reactions: currentReactions,
+ currentUserId: props.user?._id,
+ position,
+ onReactionPress: (emoji: string) => reactions?.onReactionPress?.(currentMessage, emoji),
+ containerStyle: reactions?.containerStyle,
+ reactionStyle: reactions?.reactionStyle,
+ reactionActiveStyle: reactions?.reactionActiveStyle,
+ reactionTextStyle: reactions?.reactionTextStyle,
+ reactionCountStyle: reactions?.reactionCountStyle,
+ }
+
+ if (reactions?.renderReactions)
+ return renderComponentOrElement(reactions.renderReactions, displayProps)
+
+ return
+ }, [currentMessage, position, props.user, reactions])
+
+ const renderReactionPickerModal = useCallback(() => {
+ if (!reactions?.isEnabled)
+ return null
+
+ const emojis = reactions.emojis ?? DEFAULT_REACTION_EMOJIS
+
+ const pickerProps = {
+ visible: isPickerVisible,
+ message: currentMessage,
+ emojis,
+ onSelect: (emoji: string) => reactions?.onReactionPress?.(currentMessage, emoji),
+ onDismiss: () => setIsPickerVisible(false),
+ position,
+ ...pickerAnchor,
+ pickerContainerStyle: reactions.pickerContainerStyle,
+ pickerEmojiStyle: reactions.pickerEmojiStyle,
+ }
+
+ if (reactions.renderReactionPicker)
+ return renderComponentOrElement(reactions.renderReactionPicker, pickerProps)
+
+ return
+ }, [reactions, isPickerVisible, currentMessage, position, pickerAnchor])
+
+ if (reactions?.isEnabled)
+ // Reactions path: the Animated.View carries only the scale transform,
+ // keeping the animation isolated from the static bubble styles on the
+ // inner View. Tap/long-press are handled by the composed gesture.
+ return (
+
+
+
+
+ {renderBubbleBody()}
+
+
+
+ {renderQuickReplies()}
+ {renderReactionsDisplay()}
+ {renderReactionPickerModal()}
+
+ )
+
+ // Default path: unchanged behaviour for existing users, preserving
+ // touchableProps, native press feedback, and the onLongPressMessage callback.
+ return (
+
+
- {renderBubbleContent()}
-
- {renderUsername()}
-
- {renderTime()}
- {renderTicks()}
-
-
+ {renderBubbleBody()}
{renderQuickReplies()}
diff --git a/src/Bubble/types.ts b/src/Bubble/types.ts
index 3b4fafdcc..a32f799d7 100644
--- a/src/Bubble/types.ts
+++ b/src/Bubble/types.ts
@@ -20,6 +20,7 @@ import {
MessageAudioProps,
} from '../Models'
import { QuickRepliesProps } from '../QuickReplies'
+import { ReactionsProps } from '../Reactions'
import { MessageReplyStyleProps } from '../Reply'
import { TimeProps } from '../Time'
@@ -101,4 +102,6 @@ export interface BubbleProps {
) => React.ReactNode
/** Message reply configuration */
messageReply?: BubbleReplyProps
+ /** Emoji reactions configuration */
+ reactions?: ReactionsProps
}
diff --git a/src/GiftedChat/types.ts b/src/GiftedChat/types.ts
index 0d7b854ba..7b1f66d8b 100644
--- a/src/GiftedChat/types.ts
+++ b/src/GiftedChat/types.ts
@@ -26,6 +26,7 @@ import {
User,
} from '../Models'
import { QuickRepliesProps } from '../QuickReplies'
+import { ReactionsProps } from '../Reactions'
import { ReplyProps } from '../Reply'
import { SendProps } from '../Send'
import { SystemMessageProps } from '../SystemMessage'
@@ -162,4 +163,6 @@ export interface GiftedChatProps extends Partial
+ /** Emoji reactions configuration */
+ reactions?: ReactionsProps
}
diff --git a/src/Message/types.ts b/src/Message/types.ts
index 97130884e..71db59c2a 100644
--- a/src/Message/types.ts
+++ b/src/Message/types.ts
@@ -4,6 +4,7 @@ import { AvatarProps } from '../Avatar'
import { BubbleProps } from '../Bubble'
import { DayProps } from '../Day'
import { IMessage, User, LeftRightStyle } from '../Models'
+import { ReactionsProps } from '../Reactions'
import { SwipeToReplyProps } from '../Reply'
import { SystemMessageProps } from '../SystemMessage'
@@ -23,4 +24,6 @@ export interface MessageProps {
onMessageLayout?: (event: LayoutChangeEvent) => void
/** Swipe to reply configuration */
swipeToReply?: SwipeToReplyProps
+ /** Emoji reactions configuration */
+ reactions?: ReactionsProps
}
diff --git a/src/MessagesContainer/types.ts b/src/MessagesContainer/types.ts
index b36b7c7d3..16741bb64 100644
--- a/src/MessagesContainer/types.ts
+++ b/src/MessagesContainer/types.ts
@@ -11,6 +11,7 @@ import { DayProps } from '../Day'
import { LoadEarlierMessagesProps } from '../LoadEarlierMessages'
import { MessageProps } from '../Message'
import { User, IMessage, Reply } from '../Models'
+import { ReactionsProps } from '../Reactions'
import { ReplyProps } from '../Reply'
import { TypingIndicatorProps } from '../TypingIndicator/types'
@@ -85,6 +86,8 @@ export interface MessagesContainerProps
isDayAnimationEnabled?: boolean
/** Reply functionality configuration */
reply?: ReplyProps
+ /** Emoji reactions configuration */
+ reactions?: ReactionsProps
}
export interface State {
diff --git a/src/Models.ts b/src/Models.ts
index 86b0b9d6f..b76195a3c 100644
--- a/src/Models.ts
+++ b/src/Models.ts
@@ -29,6 +29,13 @@ export interface QuickReplies {
export interface ReplyMessage extends Pick {}
+export interface MessageReaction {
+ /** The emoji character (e.g. '👍') */
+ emoji: string
+ /** IDs of the users who reacted with this emoji */
+ userIds: (string | number)[]
+}
+
export interface IMessage {
_id: string | number
text: string
@@ -43,6 +50,7 @@ export interface IMessage {
pending?: boolean
quickReplies?: QuickReplies
replyMessage?: ReplyMessage
+ reactions?: MessageReaction[]
location?: {
latitude: number
longitude: number
diff --git a/src/Reactions/MessageReactions.tsx b/src/Reactions/MessageReactions.tsx
new file mode 100644
index 000000000..4dc06d386
--- /dev/null
+++ b/src/Reactions/MessageReactions.tsx
@@ -0,0 +1,116 @@
+import React from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
+
+import { Color } from '../Color'
+import { IMessage } from '../Models'
+import { MessageReactionsDisplayProps } from './types'
+
+export const MessageReactions = (
+ props: MessageReactionsDisplayProps
+): React.ReactElement | null => {
+ const {
+ reactions,
+ currentUserId,
+ position,
+ onReactionPress,
+ containerStyle,
+ reactionStyle,
+ reactionActiveStyle,
+ reactionTextStyle,
+ reactionCountStyle,
+ } = props
+
+ if (!reactions || reactions.length === 0)
+ return null
+
+ return (
+
+ {reactions.map(reaction => {
+ const isActive = currentUserId != null && reaction.userIds.includes(currentUserId)
+ const count = reaction.userIds.length
+
+ return (
+ onReactionPress?.(reaction.emoji)}
+ style={({ pressed }) => [
+ styles.pill,
+ isActive ? styles.pillActive : styles.pillInactive,
+ isActive ? reactionActiveStyle : reactionStyle,
+ pressed && styles.pillPressed,
+ ]}
+ >
+ {reaction.emoji}
+ {count > 1 && (
+
+ {count}
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginTop: 3,
+ gap: 4,
+ },
+ containerLeft: {
+ justifyContent: 'flex-start',
+ paddingLeft: 4,
+ },
+ containerRight: {
+ justifyContent: 'flex-end',
+ paddingRight: 4,
+ },
+ pill: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: 12,
+ paddingHorizontal: 7,
+ paddingVertical: 3,
+ borderWidth: 1,
+ },
+ pillInactive: {
+ backgroundColor: 'rgba(0, 0, 0, 0.06)',
+ borderColor: 'transparent',
+ },
+ pillActive: {
+ backgroundColor: 'rgba(0, 132, 255, 0.15)',
+ borderColor: Color.defaultBlue,
+ },
+ pillPressed: {
+ opacity: 0.7,
+ },
+ emoji: {
+ fontSize: 15,
+ lineHeight: 20,
+ },
+ count: {
+ fontSize: 12,
+ marginLeft: 3,
+ color: Color.black,
+ lineHeight: 20,
+ },
+ countActive: {
+ color: Color.defaultBlue,
+ fontWeight: '600',
+ },
+})
diff --git a/src/Reactions/ReactionPicker.tsx b/src/Reactions/ReactionPicker.tsx
new file mode 100644
index 000000000..dfe035f4a
--- /dev/null
+++ b/src/Reactions/ReactionPicker.tsx
@@ -0,0 +1,137 @@
+import React, { useCallback } from 'react'
+import {
+ Dimensions,
+ Modal,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native'
+
+import { IMessage } from '../Models'
+import { ReactionPickerProps } from './types'
+
+const PICKER_HEIGHT = 54
+const EMOJI_BUTTON_SIZE = 46
+const PICKER_PADDING_H = 8
+const PICKER_VERTICAL_OFFSET = 8
+
+/**
+ * Lightweight quick-picker shown on long-press: a floating row of emojis
+ * anchored to the pressed bubble. For a full emoji browser, pass a custom
+ * component via `reactions.renderReactionPicker` (see the example app).
+ */
+export const ReactionPicker = (
+ props: ReactionPickerProps
+): React.ReactElement | null => {
+ const {
+ visible,
+ emojis,
+ onSelect,
+ onDismiss,
+ position,
+ pageX = 0,
+ pageY = 0,
+ bubbleWidth = 0,
+ bubbleHeight = 0,
+ pickerContainerStyle,
+ pickerEmojiStyle,
+ } = props
+
+ const { width: screenWidth } = Dimensions.get('window')
+
+ const pickerWidth = emojis.length * EMOJI_BUTTON_SIZE + PICKER_PADDING_H * 2
+
+ const showAbove = pageY >= PICKER_HEIGHT + PICKER_VERTICAL_OFFSET
+ const pickerTop = showAbove
+ ? pageY - PICKER_HEIGHT - PICKER_VERTICAL_OFFSET
+ : pageY + bubbleHeight + PICKER_VERTICAL_OFFSET
+
+ let pickerLeft: number
+ if (position === 'right')
+ pickerLeft = pageX + bubbleWidth - pickerWidth
+ else
+ pickerLeft = pageX
+
+ pickerLeft = Math.max(8, Math.min(pickerLeft, screenWidth - pickerWidth - 8))
+
+ const handleSelect = useCallback(
+ (emoji: string) => {
+ onSelect(emoji)
+ onDismiss()
+ },
+ [onSelect, onDismiss]
+ )
+
+ if (!visible)
+ return null
+
+ return (
+
+
+
+
+ {emojis.map(emoji => (
+ handleSelect(emoji)}
+ style={({ pressed }) => [
+ styles.emojiButton,
+ pressed && styles.emojiButtonPressed,
+ ]}
+ >
+ {emoji}
+
+ ))}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ picker: {
+ position: 'absolute',
+ height: PICKER_HEIGHT,
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: '#ffffff',
+ borderRadius: PICKER_HEIGHT / 2,
+ paddingHorizontal: PICKER_PADDING_H,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 12,
+ elevation: 8,
+ },
+ emojiButton: {
+ width: EMOJI_BUTTON_SIZE,
+ height: EMOJI_BUTTON_SIZE,
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: EMOJI_BUTTON_SIZE / 2,
+ },
+ emojiButtonPressed: {
+ backgroundColor: 'rgba(0, 0, 0, 0.07)',
+ transform: [{ scale: 1.2 }],
+ },
+ emoji: {
+ fontSize: 26,
+ lineHeight: 32,
+ },
+})
diff --git a/src/Reactions/index.ts b/src/Reactions/index.ts
new file mode 100644
index 000000000..c39c2dee7
--- /dev/null
+++ b/src/Reactions/index.ts
@@ -0,0 +1,6 @@
+export * from './types'
+export { MessageReactions } from './MessageReactions'
+export { ReactionPicker } from './ReactionPicker'
+
+/** Default set of emojis shown in the reaction quick-picker */
+export const DEFAULT_REACTION_EMOJIS: string[] = ['👍', '❤️', '😂', '😮', '😢', '👎']
diff --git a/src/Reactions/types.ts b/src/Reactions/types.ts
new file mode 100644
index 000000000..837491d82
--- /dev/null
+++ b/src/Reactions/types.ts
@@ -0,0 +1,82 @@
+import React from 'react'
+import { StyleProp, TextStyle, ViewStyle } from 'react-native'
+
+import { IMessage, MessageReaction } from '../Models'
+
+/** Props passed to the default (or custom) reactions-display component rendered below each bubble */
+export interface MessageReactionsDisplayProps {
+ message: TMessage
+ reactions: MessageReaction[]
+ currentUserId?: string | number
+ position: 'left' | 'right'
+ onReactionPress?: (emoji: string) => void
+ containerStyle?: StyleProp
+ reactionStyle?: StyleProp
+ reactionActiveStyle?: StyleProp
+ reactionTextStyle?: StyleProp
+ reactionCountStyle?: StyleProp
+}
+
+/** Props passed to the default (or custom) reaction-picker component shown on long-press */
+export interface ReactionPickerProps {
+ visible: boolean
+ message: TMessage
+ emojis: string[]
+ onSelect: (emoji: string) => void
+ onDismiss: () => void
+ position: 'left' | 'right'
+ /** Horizontal screen-coordinate of the bubble's top-left corner */
+ pageX?: number
+ /** Vertical screen-coordinate of the bubble's top edge */
+ pageY?: number
+ /** Measured width of the bubble container */
+ bubbleWidth?: number
+ /** Measured height of the bubble container */
+ bubbleHeight?: number
+ pickerContainerStyle?: StyleProp
+ pickerEmojiStyle?: StyleProp
+}
+
+/**
+ * Top-level emoji-reactions configuration.
+ * Pass this as the `reactions` prop to ``.
+ */
+export interface ReactionsProps {
+ /**
+ * Enable emoji reactions on messages.
+ * @default false
+ */
+ isEnabled?: boolean
+ /**
+ * Emoji options shown in the quick picker.
+ * @default ['👍', '❤️', '😂', '😮', '😢', '👎']
+ */
+ emojis?: string[]
+ /**
+ * Called when the user selects an emoji in the picker or taps an existing
+ * reaction pill. Toggle logic is left to the consumer.
+ */
+ onReactionPress?: (message: TMessage, emoji: string) => void
+ /** Override the reactions-display component rendered below the bubble */
+ renderReactions?: (props: MessageReactionsDisplayProps) => React.ReactNode
+ /**
+ * Override the emoji-picker component shown on long-press.
+ * Use this to provide a richer picker (e.g. a full emoji browser).
+ * See the example app for a `react-native-emoji-chooser` integration.
+ */
+ renderReactionPicker?: (props: ReactionPickerProps) => React.ReactNode
+ /** Style for the container wrapping all reaction pills */
+ containerStyle?: StyleProp
+ /** Style applied to every inactive reaction pill */
+ reactionStyle?: StyleProp
+ /** Style applied to the current-user's active reaction pill */
+ reactionActiveStyle?: StyleProp
+ /** Style for the emoji text inside each pill */
+ reactionTextStyle?: StyleProp
+ /** Style for the count label inside each pill */
+ reactionCountStyle?: StyleProp
+ /** Style for the floating quick-picker panel */
+ pickerContainerStyle?: StyleProp
+ /** Style for each emoji button inside the quick picker */
+ pickerEmojiStyle?: StyleProp
+}
diff --git a/src/index.ts b/src/index.ts
index c799d4946..5a642d96e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -7,6 +7,7 @@ export * from './GiftedChatContext'
export * from './types'
export * from './linkParser'
export * from './Reply'
+export * from './Reactions'
export * from './styles'
export { Color } from './Color'
export { Actions } from './Actions'