diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index c547cbda03..f275f8ad8a 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -386,6 +386,7 @@ export type ChannelPropsWithContext = Pick & | 'MessageUserReactionsItem' | 'ReactionListBottom' | 'reactionListPosition' + | 'reactionListType' | 'ReactionListTop' | 'Reply' | 'shouldShowUnreadUnderlay' @@ -708,6 +709,7 @@ const ChannelWithContext = (props: PropsWithChildren) = PollContent, ReactionListBottom = ReactionListBottomDefault, reactionListPosition = 'top', + reactionListType = 'segmented', ReactionListTop = ReactionListTopDefault, Reply = ReplyDefault, ScrollToBottomButton = ScrollToBottomButtonDefault, @@ -1960,6 +1962,7 @@ const ChannelWithContext = (props: PropsWithChildren) = PollContent, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, removeMessage, Reply, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 1fba6d9da3..2d04824d2e 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -86,6 +86,7 @@ export const useCreateMessagesContext = ({ PollContent, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, removeMessage, Reply, @@ -198,6 +199,7 @@ export const useCreateMessagesContext = ({ PollContent, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, removeMessage, Reply, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 7e9dcd714a..9bb07859d8 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -248,9 +248,9 @@ export type MessagePropsWithContext = Pick< */ const MessageWithContext = (props: MessagePropsWithContext) => { const [isErrorInMessage, setIsErrorInMessage] = useState(false); - const [showMessageReactions, setShowMessageReactions] = useState(false); + const [showMessageReactions, setShowMessageReactions] = useState(false); + const [selectedReaction, setSelectedReaction] = useState(undefined); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); - // const [selectedReaction, setSelectedReaction] = useState(undefined); const { channel, @@ -349,10 +349,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } }); - const showReactionsOverlay = useStableCallback(() => { - if (!showMessageReactions) { - setShowMessageReactions(true); - } + const showReactionsOverlay = useStableCallback((reactionType?: string) => { + setShowMessageReactions(true); + setSelectedReaction(reactionType); }); const { setNativeScrollability } = useMessageListItemContext(); @@ -880,6 +879,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { message={message} MessageUserReactionsAvatar={MessageUserReactionsAvatar} MessageUserReactionsItem={MessageUserReactionsItem} + selectedReaction={selectedReaction} /> ) : null} diff --git a/package/src/components/Message/MessageSimple/MessageSimple.tsx b/package/src/components/Message/MessageSimple/MessageSimple.tsx index e70ddb6089..5bdb6b622e 100644 --- a/package/src/components/Message/MessageSimple/MessageSimple.tsx +++ b/package/src/components/Message/MessageSimple/MessageSimple.tsx @@ -75,6 +75,7 @@ export type MessageSimplePropsWithContext = Pick< | 'messageSwipeToReplyHitSlop' | 'ReactionListBottom' | 'reactionListPosition' + | 'reactionListType' | 'ReactionListTop' > & { /** @@ -113,6 +114,7 @@ const MessageSimpleWithContext = forwardRef otherAttachments, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, shouldRenderSwipeableWrapper, setQuotedMessage, @@ -274,7 +276,7 @@ const MessageSimpleWithContext = forwardRef {reactionListPosition === 'bottom' && ReactionListBottom ? ( - + ) : null} @@ -452,6 +454,7 @@ export const MessageSimple = forwardRef((props, ref) = myMessageTheme, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, } = useMessagesContext(); const isAIGenerated = useMemo( @@ -486,6 +489,7 @@ export const MessageSimple = forwardRef((props, ref) = otherAttachments, ReactionListBottom, reactionListPosition, + reactionListType, ReactionListTop, setQuotedMessage, shouldRenderSwipeableWrapper, diff --git a/package/src/components/Message/MessageSimple/ReactionList/ReactionListBottom.tsx b/package/src/components/Message/MessageSimple/ReactionList/ReactionListBottom.tsx index 0aa031c85c..437c0ca57a 100644 --- a/package/src/components/Message/MessageSimple/ReactionList/ReactionListBottom.tsx +++ b/package/src/components/Message/MessageSimple/ReactionList/ReactionListBottom.tsx @@ -1,5 +1,7 @@ -import React, { useCallback, useRef } from 'react'; -import { Animated, FlatList, Pressable, StyleSheet, Text } from 'react-native'; +import React, { useMemo } from 'react'; +import { FlatList, StyleSheet, Text, View } from 'react-native'; + +import { ReactionListItemWrapper } from './ReactionListItemWrapper'; import { MessageContextValue, @@ -15,6 +17,7 @@ import { Unknown } from '../../../../icons/Unknown'; import type { IconProps } from '../../../../icons/utils/base'; +import { primitives } from '../../../../theme'; import type { ReactionData } from '../../../../utils/utils'; import { ReactionSummary } from '../../hooks/useProcessReactions'; @@ -28,7 +31,7 @@ const Icon = ({ pathFill, size, style, supportedReactions, type }: Props) => { const ReactionIcon = supportedReactions?.find((reaction) => reaction.type === type)?.Icon || Unknown; - return ; + return ; }; export type ReactionListBottomItemProps = Partial< @@ -44,6 +47,8 @@ export type ReactionListBottomItemProps = Partial< > & Partial> & { reaction: ReactionSummary; + showCount?: boolean; + selected?: boolean; }; export const ReactionListBottomItem = (props: ReactionListBottomItemProps) => { @@ -56,44 +61,22 @@ export const ReactionListBottomItem = (props: ReactionListBottomItemProps) => { reaction, showReactionsOverlay, supportedReactions, + showCount = true, + selected = false, } = props; - const scaleValue = useRef(new Animated.Value(1)).current; const { theme: { - colors: { accent_blue, black, light_blue, grey, grey_gainsboro }, messageSimple: { reactionListBottom: { - item: { - container, - countText, - filledBackgroundColor = light_blue, - icon, - iconFillColor = accent_blue, - iconSize, - iconUnFillColor = grey, - unfilledBackgroundColor = grey_gainsboro, - }, + item: { icon, iconSize }, }, }, }, } = useTheme(); - - const onPressInAnimation = useCallback(() => { - Animated.spring(scaleValue, { - toValue: 0.8, - useNativeDriver: true, - }).start(); - }, [scaleValue]); - - const onPressOutAnimation = useCallback(() => { - Animated.spring(scaleValue, { - toValue: 1, - useNativeDriver: true, - }).start(); - }, [scaleValue]); + const styles = useStyles({}); return ( - { } }} onPressIn={(event) => { - onPressInAnimation(); if (onPressIn) { onPressIn({ defaultHandler: () => { @@ -137,29 +119,17 @@ export const ReactionListBottomItem = (props: ReactionListBottomItemProps) => { }); } }} - onPressOut={onPressOutAnimation} + selected={selected} > - - - {reaction.count} - - + + {showCount ? {reaction.count} : null} + ); }; @@ -174,12 +144,15 @@ const renderItem = ({ index, item }: { index: number; item: ReactionListBottomIt reaction={item.reaction} showReactionsOverlay={item.showReactionsOverlay} supportedReactions={item.supportedReactions} + selected={item.reaction.own} + showCount={item.showCount} /> ); export type ReactionListBottomProps = Partial< Pick< MessageContextValue, + | 'alignment' | 'handleReaction' | 'hasReactions' | 'onLongPress' @@ -190,10 +163,19 @@ export type ReactionListBottomProps = Partial< | 'showReactionsOverlay' > > & - Partial>; + Partial> & { + type?: 'clustered' | 'segmented'; + showCount?: boolean; + }; + +const ItemSeparatorComponent = () => { + const styles = useStyles({}); + return ; +}; export const ReactionListBottom = (props: ReactionListBottomProps) => { const { + alignment: propAlignment, handleReaction: propHandlerReaction, hasReactions: propHasReactions, onLongPress: propOnLongPress, @@ -203,9 +185,12 @@ export const ReactionListBottom = (props: ReactionListBottomProps) => { reactions: propReactions, showReactionsOverlay: propShowReactionsOverlay, supportedReactions: propSupportedReactions, + type, + showCount = true, } = props; const { + alignment: contextAlignment, handleReaction: contextHandleReaction, hasReactions: contextHasReactions, onLongPress: contextOnLongPress, @@ -218,6 +203,7 @@ export const ReactionListBottom = (props: ReactionListBottomProps) => { const { supportedReactions: contextSupportedReactions } = useMessagesContext(); + const alignment = propAlignment || contextAlignment; const handleReaction = propHandlerReaction || contextHandleReaction; const hasReactions = propHasReactions || contextHasReactions; const onLongPress = propOnLongPress || contextOnLongPress; @@ -225,19 +211,25 @@ export const ReactionListBottom = (props: ReactionListBottomProps) => { const onPressIn = propOnPressIn || contextOnPressIn; const preventPress = propPreventPress || contextPreventPress; const reactions = propReactions || contextReactions; - const showMessageOverlay = propShowReactionsOverlay || contextShowReactionsOverlay; + const showReactionsOverlay = propShowReactionsOverlay || contextShowReactionsOverlay; const supportedReactions = propSupportedReactions || contextSupportedReactions; const { theme: { messageSimple: { - reactionListBottom: { contentContainer }, + reactionListBottom: { + item: { iconSize, icon }, + }, }, }, } = useTheme(); - + const styles = useStyles({ messageAlignment: alignment }); const supportedReactionTypes = supportedReactions?.map( (supportedReaction) => supportedReaction.type, ); + const reactionsCount = reactions.length; + const moreReactionsCount = reactionsCount - 4; + const reactionsCountText = + moreReactionsCount < 99 ? moreReactionsCount : `+${moreReactionsCount}`; const hasSupportedReactions = reactions.some((reaction) => supportedReactionTypes?.includes(reaction.type), @@ -254,38 +246,112 @@ export const ReactionListBottom = (props: ReactionListBottomProps) => { onPressIn, preventPress, reaction, - showMessageOverlay, + showReactionsOverlay, supportedReactions, + showCount, })); - return ( - item.reaction.type} - numColumns={6} - renderItem={renderItem} - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - /> - ); + if (type === 'segmented') { + return ( + item.reaction.type} + ItemSeparatorComponent={ItemSeparatorComponent} // This is for the gap between the rows of reactions + renderItem={renderItem} + showsHorizontalScrollIndicator={false} + showsVerticalScrollIndicator={false} + /> + ); + } else { + return ( + { + if (onPress) { + onPress({ + defaultHandler: () => { + if (showReactionsOverlay) { + showReactionsOverlay(undefined); + } + }, + emitter: 'reactionList', + event, + }); + } + }} + onPressIn={(event) => { + if (onPressIn) { + onPressIn({ + defaultHandler: () => { + if (showReactionsOverlay) { + showReactionsOverlay(undefined); + } + }, + emitter: 'reactionList', + event, + }); + } + }} + > + {reactions.slice(0, 4).map((reaction) => ( + + ))} + {reactionsCount > 4 ? {reactionsCountText} : null} + + ); + } }; -const styles = StyleSheet.create({ - contentContainer: { - alignSelf: 'flex-end', - }, - itemContainer: { - alignItems: 'center', - borderRadius: 12, - flexDirection: 'row', - justifyContent: 'center', - margin: 2, - padding: 8, - }, - reactionCount: { - fontWeight: '600', - marginLeft: 4, - }, -}); +const useStyles = ({ + messageAlignment, +}: { + messageAlignment?: MessageContextValue['alignment']; +}) => { + const { + theme: { + semantics, + messageSimple: { + reactionListBottom: { + contentContainer, + columnWrapper, + rowSeparator, + item: { countText }, + }, + }, + }, + } = useTheme(); + return useMemo( + () => + StyleSheet.create({ + columnWrapper: { + gap: primitives.spacingXxs, // Horizontal spacing between items + justifyContent: messageAlignment === 'right' ? 'flex-end' : 'flex-start', + ...columnWrapper, + }, + contentContainer: { + ...contentContainer, + }, + reactionCount: { + color: semantics.reactionText, + fontSize: primitives.typographyFontSizeXxs, + fontWeight: primitives.typographyFontWeightBold, + lineHeight: primitives.typographyLineHeightTight, + ...countText, + }, + itemSeparator: { + height: primitives.spacingXxs, + ...rowSeparator, + }, + }), + [semantics, countText, contentContainer, columnWrapper, rowSeparator, messageAlignment], + ); +}; diff --git a/package/src/components/Message/MessageSimple/ReactionList/ReactionListItemWrapper.tsx b/package/src/components/Message/MessageSimple/ReactionList/ReactionListItemWrapper.tsx new file mode 100644 index 0000000000..bb478bac5e --- /dev/null +++ b/package/src/components/Message/MessageSimple/ReactionList/ReactionListItemWrapper.tsx @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import { Pressable, PressableProps, StyleProp, StyleSheet, ViewStyle } from 'react-native'; + +import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; +import { primitives } from '../../../../theme'; + +type ReactionListItemWrapperProps = PressableProps & { + selected?: boolean; + style?: StyleProp; +}; + +export const ReactionListItemWrapper = (props: ReactionListItemWrapperProps) => { + const { children, selected = false, style, ...rest } = props; + const styles = useStyles(); + const { + theme: { semantics }, + } = useTheme(); + + return ( + [ + styles.container, + { + backgroundColor: selected + ? semantics.backgroundCoreSelected + : pressed + ? semantics.backgroundCorePressed + : semantics.reactionBg, + }, + style, + ]} + {...rest} + > + {children} + + ); +}; + +const useStyles = () => { + const { + theme: { semantics }, + } = useTheme(); + return useMemo(() => { + return StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + borderWidth: 1, + borderRadius: primitives.radiusMax, + borderColor: semantics.reactionBorder, + + paddingVertical: primitives.spacingXxs, + paddingHorizontal: primitives.spacingXs, + gap: primitives.spacingXxs, + }, + }); + }, [semantics]); +}; diff --git a/package/src/components/Message/MessageSimple/__tests__/ReactionListBottom.test.js b/package/src/components/Message/MessageSimple/__tests__/ReactionListBottom.test.js index 94a11cb19b..c2c19890b6 100644 --- a/package/src/components/Message/MessageSimple/__tests__/ReactionListBottom.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/ReactionListBottom.test.js @@ -1,7 +1,5 @@ import React from 'react'; -import { Animated } from 'react-native'; - import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; @@ -98,47 +96,49 @@ describe('ReactionListBottom', () => { }); }); - it('applies animation on press in', () => { - const animatedSpy = jest.spyOn(Animated, 'spring'); - const user = generateUser(); - const reaction = generateReaction(); - const message = generateMessage({ - reaction_groups: { [reaction.type]: reaction }, - user, - }); + // As discussed with Design team, the animation is not needed for now. Once we have it, we can add it. - renderMessage({ message }, { reactionListPosition: 'bottom' }); + // it('applies animation on press in', () => { + // const animatedSpy = jest.spyOn(Animated, 'spring'); + // const user = generateUser(); + // const reaction = generateReaction(); + // const message = generateMessage({ + // reaction_groups: { [reaction.type]: reaction }, + // user, + // }); - const reactionListBottomItem = screen.getByLabelText('Reaction List Bottom Item'); + // renderMessage({ message }, { reactionListPosition: 'bottom' }); - fireEvent(reactionListBottomItem, 'onPressIn'); + // const reactionListBottomItem = screen.getByLabelText('Reaction List Bottom Item'); - expect(animatedSpy).toHaveBeenCalledWith(expect.any(Animated.Value), { - toValue: 0.8, - useNativeDriver: true, - }); - }); + // fireEvent(reactionListBottomItem, 'onPressIn'); - it('applies animation on press out', () => { - const animatedSpy = jest.spyOn(Animated, 'spring'); - const user = generateUser(); - const reaction = generateReaction(); - const message = generateMessage({ - reaction_groups: { [reaction.type]: reaction }, - user, - }); + // expect(animatedSpy).toHaveBeenCalledWith(expect.any(Animated.Value), { + // toValue: 0.8, + // useNativeDriver: true, + // }); + // }); - renderMessage({ message }, { reactionListPosition: 'bottom' }); + // it('applies animation on press out', () => { + // const animatedSpy = jest.spyOn(Animated, 'spring'); + // const user = generateUser(); + // const reaction = generateReaction(); + // const message = generateMessage({ + // reaction_groups: { [reaction.type]: reaction }, + // user, + // }); - const reactionListBottomItem = screen.getByLabelText('Reaction List Bottom Item'); + // renderMessage({ message }, { reactionListPosition: 'bottom' }); - fireEvent(reactionListBottomItem, 'onPressOut'); + // const reactionListBottomItem = screen.getByLabelText('Reaction List Bottom Item'); - expect(animatedSpy).toHaveBeenCalledWith(expect.any(Animated.Value), { - toValue: 1, - useNativeDriver: true, - }); - }); + // fireEvent(reactionListBottomItem, 'onPressOut'); + + // expect(animatedSpy).toHaveBeenCalledWith(expect.any(Animated.Value), { + // toValue: 1, + // useNativeDriver: true, + // }); + // }); it('call handleReaction on press', () => { const handleReactionMock = jest.fn(); diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index d8d6277a8e..affcf9c444 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; @@ -30,6 +30,15 @@ import { ReactionData } from '../../utils/utils'; import { Button } from '../ui'; import { StreamBottomSheetModalFlatList } from '../UIComponents'; +const ITEM_WIDTH = 60; + +// @ts-ignore +const getItemLayout = (_, index: number) => ({ + length: ITEM_WIDTH, + offset: ITEM_WIDTH * index, + index, +}); + export type MessageUserReactionsProps = Partial< Pick< MessagesContextValue, @@ -98,6 +107,7 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { selectedReaction: propSelectedReaction, supportedReactions: propSupportedReactions, } = props; + const selectorListRef = useRef(null); const { close } = useBottomSheetContext(); const reactionTypes = useMemo( () => Object.keys(message?.reaction_groups ?? {}), @@ -152,6 +162,22 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { ], ); + const selectedIndex = useMemo(() => { + if (!propSelectedReaction) { + return -1; + } + return selectorReactions.findIndex((reaction) => reaction.type === propSelectedReaction); + }, [propSelectedReaction, selectorReactions]); + + useEffect(() => { + if (selectedIndex !== -1 && selectorListRef.current) { + selectorListRef.current?.scrollToIndex({ + index: selectedIndex + 1, // +1 to account for the show more reactions button + animated: true, + }); + } + }, [selectedIndex]); + const { loading, loadNextPage, @@ -267,10 +293,12 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 58b2ede8d4..36e3c22ded 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -583,6 +583,11 @@ export type MessagesContextValue = Pick