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'