UNPKG

react-native-gifted-chat

Version:

The most complete chat UI for React Native. Maintenance mode - active development continues in @kesha-antonov/react-native-chat

391 lines • 16.4 kB
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'; import { MessageAudio } from '../MessageAudio'; import { MessageImage } from '../MessageImage'; import { MessageText } from '../MessageText'; import { MessageVideo } from '../MessageVideo'; 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'; import styles from './styles'; export * from './types'; 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) => { const { currentMessage, nextMessage, position, containerToNextStyle, previousMessage, containerToPreviousStyle, onQuickReply, renderQuickReplySend, quickReplyStyle, quickReplyTextStyle, quickReplyContainerStyle, containerStyle, wrapperStyle, 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]); const onLongPress = useCallback(() => { onLongPressMessageProp?.(context, currentMessage); }, [ currentMessage, context, 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 && nextMessage && position && isSameUser(currentMessage, nextMessage) && isSameDay(currentMessage, nextMessage)) return [ getStyleWithPosition(styles, 'containerToNext', position), containerToNextStyle?.[position], ]; return null; }, [ currentMessage, nextMessage, position, containerToNextStyle, ]); const styledBubbleToPrevious = useMemo(() => { if (currentMessage && previousMessage && position && isSameUser(currentMessage, previousMessage) && isSameDay(currentMessage, previousMessage)) return [ getStyleWithPosition(styles, 'containerToPrevious', position), containerToPreviousStyle?.[position], ]; return null; }, [ currentMessage, previousMessage, position, containerToPreviousStyle, ]); const renderQuickReplies = useCallback(() => { if (currentMessage?.quickReplies) { const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, /* eslint-enable @typescript-eslint/no-unused-vars */ ...quickReplyProps } = props; if (props.renderQuickReplies) return renderComponentOrElement(props.renderQuickReplies, quickReplyProps); return (<QuickReplies currentMessage={currentMessage} onQuickReply={onQuickReply} renderQuickReplySend={renderQuickReplySend} quickReplyStyle={quickReplyStyle} quickReplyTextStyle={quickReplyTextStyle} quickReplyContainerStyle={quickReplyContainerStyle} nextMessage={nextMessage}/>); } return null; }, [ currentMessage, onQuickReply, renderQuickReplySend, quickReplyStyle, quickReplyTextStyle, quickReplyContainerStyle, nextMessage, props, ]); const renderMessageText = useCallback(() => { if (currentMessage?.text) { const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, messageTextProps, /* eslint-enable @typescript-eslint/no-unused-vars */ ...messageTextPropsRest } = props; const combinedProps = { ...messageTextPropsRest, ...messageTextProps, }; if (props.renderMessageText) return renderComponentOrElement(props.renderMessageText, combinedProps); return <MessageText {...combinedProps}/>; } return null; }, [props, currentMessage]); const renderMessageImage = useCallback(() => { if (currentMessage?.image) { const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, /* eslint-enable @typescript-eslint/no-unused-vars */ ...messageImageProps } = props; if (props.renderMessageImage) return renderComponentOrElement(props.renderMessageImage, messageImageProps); return <MessageImage {...messageImageProps}/>; } return null; }, [props, currentMessage]); const renderMessageVideo = useCallback(() => { if (!currentMessage?.video) return null; const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, /* eslint-enable @typescript-eslint/no-unused-vars */ ...messageVideoProps } = props; if (props.renderMessageVideo) return renderComponentOrElement(props.renderMessageVideo, messageVideoProps); return <MessageVideo />; }, [props, currentMessage]); const renderMessageAudio = useCallback(() => { if (!currentMessage?.audio) return null; const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, /* eslint-enable @typescript-eslint/no-unused-vars */ ...messageAudioProps } = props; if (props.renderMessageAudio) return renderComponentOrElement(props.renderMessageAudio, messageAudioProps); return <MessageAudio />; }, [props, currentMessage]); const renderTicks = useCallback(() => { const { renderTicks, user, } = props; if (renderTicks && currentMessage) return renderComponentOrElement(renderTicks, currentMessage); if (user && currentMessage?.user && currentMessage.user._id !== user._id) return null; if (currentMessage && (currentMessage.sent || currentMessage.received || currentMessage.pending)) return (<View style={styles.messageStatusContainer}> {!!currentMessage.sent && (<Text style={[styles.messageStatus, props.tickStyle]}> {'✓'} </Text>)} {!!currentMessage.received && (<Text style={[styles.messageStatus, props.tickStyle]}> {'✓'} </Text>)} {!!currentMessage.pending && (<Text style={[styles.messageStatus, props.tickStyle]}> {'🕓'} </Text>)} </View>); return null; }, [ props, currentMessage, ]); const renderTime = useCallback(() => { if (currentMessage?.createdAt) { const { /* eslint-disable @typescript-eslint/no-unused-vars */ containerStyle, wrapperStyle, textStyle, /* eslint-enable @typescript-eslint/no-unused-vars */ ...timeProps } = props; if (props.renderTime) return renderComponentOrElement(props.renderTime, timeProps); return <Time {...timeProps}/>; } return null; }, [props, currentMessage]); const renderUsername = useCallback(() => { const { user, renderUsername, } = props; if (props.isUsernameVisible && currentMessage) { if (user && currentMessage.user._id === user._id) return null; if (renderUsername) return renderComponentOrElement(renderUsername, currentMessage.user); return (<View style={styles.usernameContainer}> <Text style={[styles.username, props.usernameStyle]}> {currentMessage.user.name} </Text> </View>); } return null; }, [ currentMessage, props, ]); const renderCustomView = useCallback(() => { if (props.renderCustomView) return renderComponentOrElement(props.renderCustomView, props); return null; }, [props]); const renderMessageReply = useCallback(() => { if (!currentMessage?.replyMessage) return null; const { messageReply } = props; const messageReplyProps = { replyMessage: currentMessage.replyMessage, currentMessage, position, onPress: messageReply?.onPress, containerStyle: position === 'left' ? messageReply?.containerStyleLeft ?? messageReply?.containerStyle : messageReply?.containerStyleRight ?? messageReply?.containerStyle, textStyle: position === 'left' ? messageReply?.textStyleLeft ?? messageReply?.textStyle : messageReply?.textStyleRight ?? messageReply?.textStyle, imageStyle: messageReply?.imageStyle, }; if (messageReply?.renderMessageReply) return renderComponentOrElement(messageReply.renderMessageReply, messageReplyProps); return <MessageReply {...messageReplyProps}/>; }, [ props, currentMessage, position, ]); const renderBubbleContent = useCallback(() => { return (<> {!props.isCustomViewBottom && renderCustomView()} {renderMessageReply()} {renderMessageImage()} {renderMessageVideo()} {renderMessageAudio()} {renderMessageText()} {props.isCustomViewBottom && renderCustomView()} </>); }, [ renderMessageReply, renderCustomView, renderMessageImage, renderMessageVideo, renderMessageAudio, renderMessageText, props.isCustomViewBottom, ]); const renderBubbleBody = useCallback(() => (<> {renderBubbleContent()} <View style={[ styles.bottom, bottomContainerStyle?.[position], ]}> {renderUsername()} <View style={styles.messageTimeAndStatusContainer}> {renderTime()} {renderTicks()} </View> </View> </>), [ 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) => 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 <MessageReactions {...displayProps}/>; }, [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) => reactions?.onReactionPress?.(currentMessage, emoji), onDismiss: () => setIsPickerVisible(false), position, ...pickerAnchor, pickerContainerStyle: reactions.pickerContainerStyle, pickerEmojiStyle: reactions.pickerEmojiStyle, }; if (reactions.renderReactionPicker) return renderComponentOrElement(reactions.renderReactionPicker, pickerProps); return <ReactionPicker {...pickerProps}/>; }, [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 (<Animated.View style={containerStyle?.[position]} ref={bubbleContainerRef}> <GestureDetector gesture={reactionsGesture}> <Animated.View style={bubbleScaleStyle}> <View style={wrapperStyleList}> {renderBubbleBody()} </View> </Animated.View> </GestureDetector> {renderQuickReplies()} {renderReactionsDisplay()} {renderReactionPickerModal()} </Animated.View>); // Default path: unchanged behaviour for existing users, preserving // touchableProps, native press feedback, and the onLongPressMessage callback. return (<View style={containerStyle?.[position]}> <View style={wrapperStyleList}> <Pressable onPress={onPress} onLongPress={onLongPress} {...props.touchableProps}> {renderBubbleBody()} </Pressable> </View> {renderQuickReplies()} </View>); }; //# sourceMappingURL=index.js.map