From af6b94b6a9659231ec1ea1d8c8ab06d68fbeb913 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Sep 2024 18:24:49 +0200 Subject: [PATCH] feat: control ReactionsSelector dialog display --- src/components/Message/Message.tsx | 21 +- src/components/Message/MessageOptions.tsx | 36 +- src/components/Message/MessageSimple.tsx | 23 +- .../Message/__tests__/MessageOptions.test.js | 83 +++- .../Message/__tests__/QuotedMessage.test.js | 9 +- .../__tests__/useReactionHandler.test.js | 197 +--------- .../Message/hooks/useReactionHandler.ts | 94 +---- src/components/Message/utils.tsx | 4 + src/components/Reactions/ReactionSelector.tsx | 360 +++++++++--------- .../Reactions/ReactionSelectorWithButton.tsx | 54 +++ .../__tests__/ReactionSelector.test.js | 15 +- src/context/MessageContext.tsx | 12 +- 12 files changed, 366 insertions(+), 542 deletions(-) create mode 100644 src/components/Reactions/ReactionSelectorWithButton.tsx diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 3b6ce5e6aa..c9a486a96c 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useActionHandler, @@ -10,7 +10,6 @@ import { useMuteHandler, useOpenThreadHandler, usePinHandler, - useReactionClick, useReactionHandler, useReactionsFetcher, useRetryHandler, @@ -44,14 +43,10 @@ type MessageContextPropsToPick = | 'handleReaction' | 'handleFetchReactions' | 'handleRetry' - | 'isReactionEnabled' | 'mutes' | 'onMentionsClickMessage' | 'onMentionsHoverMessage' - | 'onReactionListClick' - | 'reactionSelectorRef' | 'reactionDetailsSort' - | 'showDetailedReactions' | 'sortReactions' | 'sortReactionDetails'; @@ -218,8 +213,6 @@ export const Message = < const { addNotification } = useChannelActionContext('Message'); const { highlightedMessageId, mutes } = useChannelStateContext('Message'); - const reactionSelectorRef = useRef(null); - const handleAction = useActionHandler(message); const handleOpenThread = useOpenThreadHandler(message, propOpenThread); const handleReaction = useReactionHandler(message); @@ -264,13 +257,6 @@ export const Message = < notify: addNotification, }); - const { isReactionEnabled, onReactionListClick, showDetailedReactions } = useReactionClick( - message, - reactionSelectorRef, - undefined, - closeReactionSelectorOnClick, - ); - const highlighted = highlightedMessageId === message.id; return ( @@ -278,6 +264,7 @@ export const Message = < additionalMessageInputProps={props.additionalMessageInputProps} autoscrollToBottom={props.autoscrollToBottom} canPin={canPin} + closeReactionSelectorOnClick={closeReactionSelectorOnClick} customMessageActions={props.customMessageActions} disableQuotedMessages={props.disableQuotedMessages} endOfGroup={props.endOfGroup} @@ -297,7 +284,6 @@ export const Message = < handleRetry={handleRetry} highlighted={highlighted} initialMessage={props.initialMessage} - isReactionEnabled={isReactionEnabled} lastReceivedId={props.lastReceivedId} message={message} Message={props.Message} @@ -306,15 +292,12 @@ export const Message = < mutes={mutes} onMentionsClickMessage={onMentionsClick} onMentionsHoverMessage={onMentionsHover} - onReactionListClick={onReactionListClick} onUserClick={props.onUserClick} onUserHover={props.onUserHover} pinPermissions={props.pinPermissions} reactionDetailsSort={reactionDetailsSort} - reactionSelectorRef={reactionSelectorRef} readBy={props.readBy} renderText={props.renderText} - showDetailedReactions={showDetailedReactions} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} threadList={props.threadList} diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx index 760bd1c46c..f8890ea21b 100644 --- a/src/components/Message/MessageOptions.tsx +++ b/src/components/Message/MessageOptions.tsx @@ -6,13 +6,15 @@ import { ThreadIcon as DefaultThreadIcon, } from './icons'; import { MESSAGE_ACTIONS } from './utils'; - import { MessageActions } from '../MessageActions'; +import { useTranslationContext } from '../../context'; import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; import type { DefaultStreamChatGenerics, IconProps } from '../../types/types'; -import { useTranslationContext } from '../../context'; +import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton'; +import { useDialogIsOpen } from '../Dialog'; +import clsx from 'clsx'; export type MessageOptionsProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -21,8 +23,6 @@ export type MessageOptionsProps< ActionsIcon?: React.ComponentType; /* If true, show the `ThreadIcon` and enable navigation into a `Thread` component. */ displayReplies?: boolean; - /* React mutable ref that can be placed on the message root `div` of MessageActions component */ - messageWrapperRef?: React.RefObject; /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ ReactionIcon?: React.ComponentType; /* Theme string to be added to CSS class names. */ @@ -40,7 +40,6 @@ const UnMemoizedMessageOptions = < ActionsIcon = DefaultActionsIcon, displayReplies = true, handleOpenThread: propHandleOpenThread, - messageWrapperRef, ReactionIcon = DefaultReactionIcon, theme = 'simple', ThreadIcon = DefaultThreadIcon, @@ -51,13 +50,12 @@ const UnMemoizedMessageOptions = < handleOpenThread: contextHandleOpenThread, initialMessage, message, - onReactionListClick, - showDetailedReactions, threadList, } = useMessageContext('MessageOptions'); const { t } = useTranslationContext('MessageOptions'); - + const messageActionsDialogIsOpen = useDialogIsOpen(`message-actions--${message.id}`); + const reactionSelectorDialogIsOpen = useDialogIsOpen(`reaction-selector--${message.id}`); const handleOpenThread = propHandleOpenThread || contextHandleOpenThread; const messageActions = getMessageActions(); @@ -78,11 +76,15 @@ const UnMemoizedMessageOptions = < return null; } - const rootClassName = `str-chat__message-${theme}__actions str-chat__message-options`; - return ( -
- +
+ {shouldShowReplies && ( + )}
); diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 33a909521a..bc678e1a01 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -22,10 +22,7 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { EditMessageForm as DefaultEditMessageForm, MessageInput } from '../MessageInput'; import { MML } from '../MML'; import { Modal } from '../Modal'; -import { - ReactionsList as DefaultReactionList, - ReactionSelector as DefaultReactionSelector, -} from '../Reactions'; +import { ReactionsList as DefaultReactionList } from '../Reactions'; import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; import { useChatContext } from '../../context/ChatContext'; @@ -59,13 +56,10 @@ const MessageSimpleWithContext = < handleRetry, highlighted, isMyMessage, - isReactionEnabled, message, onUserClick, onUserHover, - reactionSelectorRef, renderText, - showDetailedReactions, threadList, } = props; @@ -83,7 +77,7 @@ const MessageSimpleWithContext = < MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, - ReactionSelector = DefaultReactionSelector, + ReactionsList = DefaultReactionList, PinIndicator, } = useComponentContext('MessageSimple'); @@ -100,14 +94,6 @@ const MessageSimpleWithContext = < return ; } - /** FIXME: isReactionEnabled should be removed with next major version and a proper centralized permissions logic should be put in place - * With the current permissions implementation it would be sth like: - * const messageActions = getMessageActions(); - * const canReact = messageActions.includes(MESSAGE_ACTIONS.react); - */ - const canReact = isReactionEnabled; - const canShowReactions = hasReactions; - const showMetadata = !groupedByUser || endOfGroup; const showReplyCountButton = !threadList && !!message.reply_count; const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403; @@ -136,7 +122,7 @@ const MessageSimpleWithContext = < 'str-chat__message--has-attachment': hasAttachment, 'str-chat__message--highlighted': highlighted, 'str-chat__message--pinned pinned-message': message.pinned, - 'str-chat__message--with-reactions': canShowReactions, + 'str-chat__message--with-reactions': hasReactions, 'str-chat__message-send-can-be-retried': message?.status === 'failed' && message?.errorStatusCode !== 403, 'str-chat__message-with-thread-link': showReplyCountButton, @@ -190,8 +176,7 @@ const MessageSimpleWithContext = < >
- {canShowReactions && } - {showDetailedReactions && canReact && } + {hasReactions && }
{message.attachments?.length && !message.quoted_message ? ( diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index 8b93092917..f3b569c56b 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -1,6 +1,6 @@ /* eslint-disable jest-dom/prefer-to-have-class */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Message } from '../Message'; @@ -22,6 +22,7 @@ import { getTestClientWithUser, } from '../../../mock-builders'; import { DialogsManagerProvider } from '../../../context'; +import { defaultReactionOptions } from '../../Reactions'; const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; @@ -73,6 +74,7 @@ async function renderMessageOptions({ onReactionListClick={customMessageProps?.onReactionListClick} /> ), + reactionOptions: defaultReactionOptions, }} > @@ -182,6 +184,85 @@ describe('', () => { expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument(); }); + it('should not render ReactionsSelector until open', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + expect(screen.getByTestId('reaction-selector')).toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed by click on dialog overlay', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.getByTestId('str-chat__dialog-overlay')); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed pressed Esc button', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: true, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should not unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: false, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).toBeInTheDocument(); + }); + it('should render message actions', async () => { const { queryByTestId } = await renderMessageOptions({ channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions }, diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js index 67c9cf6ae4..b076be757d 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.js +++ b/src/components/Message/__tests__/QuotedMessage.test.js @@ -9,6 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + DialogsManagerProvider, TranslationProvider, } from '../../../context'; import { @@ -65,9 +66,11 @@ async function renderQuotedMessage(customProps) { Message: () => , }} > - - - + + + + + diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.js b/src/components/Message/hooks/__tests__/useReactionHandler.test.js index 04a03f1c49..3b61291ff5 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.js +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.js @@ -1,11 +1,7 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; -import { - reactionHandlerWarning, - useReactionClick, - useReactionHandler, -} from '../useReactionHandler'; +import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandler'; import { ChannelActionProvider } from '../../../../context/ChannelActionContext'; import { ChannelStateProvider } from '../../../../context/ChannelStateContext'; @@ -123,192 +119,3 @@ describe('useReactionHandler custom hook', () => { expect(updateMessage).toHaveBeenCalledWith(message); }); }); - -function renderUseReactionClickHook( - message = generateMessage(), - reactionListRef = React.createRef(), - messageWrapperRef = React.createRef(), -) { - const channel = generateChannel(); - - const wrapper = ({ children }) => ( - - {children} - - ); - - const { rerender, result } = renderHook( - () => useReactionClick(message, reactionListRef, messageWrapperRef), - { wrapper }, - ); - return { rerender, result }; -} - -describe('useReactionClick custom hook', () => { - beforeEach(jest.clearAllMocks); - it('should initialize a click handler and a flag for showing detailed reactions', () => { - const { - result: { current }, - } = renderUseReactionClickHook(); - - expect(typeof current.onReactionListClick).toBe('function'); - expect(current.showDetailedReactions).toBe(false); - }); - - it('should set show details to true on click', async () => { - const { result } = renderUseReactionClickHook(); - expect(result.current.showDetailedReactions).toBe(false); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - }); - - it('should return correct value for isReactionEnabled', () => { - const channel = generateChannel(); - const channelCapabilities = { 'send-reaction': true }; - - const { rerender, result } = renderHook( - () => useReactionClick(generateMessage(), React.createRef(), React.createRef()), - { - // eslint-disable-next-line react/display-name - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - expect(result.current.isReactionEnabled).toBe(true); - channelCapabilities['send-reaction'] = false; - rerender(); - expect(result.current.isReactionEnabled).toBe(false); - channelCapabilities['send-reaction'] = true; - rerender(); - expect(result.current.isReactionEnabled).toBe(true); - }); - - it('should set event listener to close reaction list on document click when list is opened', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - expect(document.addEventListener).toHaveBeenCalledTimes(1); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(false); - addEventListenerSpy.mockRestore(); - }); - - it('should set event listener to message wrapper reference when one is set', async () => { - const mockMessageWrapperReference = { - current: { - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - }, - }; - const { result } = renderUseReactionClickHook( - generateMessage(), - React.createRef(), - mockMessageWrapperReference, - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(mockMessageWrapperReference.current.addEventListener).toHaveBeenCalledWith( - 'mouseleave', - expect.any(Function), - ); - }); - - it('should not close reaction list on document click when click is on the reaction list itself', async () => { - const message = generateMessage(); - const reactionSelectorEl = document.createElement('div'); - const reactionListElement = document.createElement('div').appendChild(reactionSelectorEl); - const clickMock = { - target: reactionSelectorEl, - }; - const { result } = renderUseReactionClickHook(message, { - current: reactionListElement, - }); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(true); - addEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners after reaction list is closed', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - act(() => onDocumentClick(clickMock)); - expect(result.current.showDetailedReactions).toBe(false); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners if message is deleted', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const message = generateMessage(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - const { rerender, result } = renderUseReactionClickHook(message); - expect(document.removeEventListener).not.toHaveBeenCalled(); - await act(() => { - result.current.onReactionListClick(clickMock); - }); - message.deleted_at = new Date(); - rerender(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); -}); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index b7275801df..2565d00bb2 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -1,12 +1,10 @@ -import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback } from 'react'; import throttle from 'lodash.throttle'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { StreamMessage, useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; -import type { ReactEventHandler } from '../types'; - import type { Reaction, ReactionResponse } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -141,93 +139,3 @@ export const useReactionHandler = < } }; }; - -export const useReactionClick = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->( - message?: StreamMessage, - reactionSelectorRef?: RefObject, - messageWrapperRef?: RefObject, - closeReactionSelectorOnClick?: boolean, -) => { - const { channelCapabilities = {} } = useChannelStateContext( - 'useReactionClick', - ); - - const [showDetailedReactions, setShowDetailedReactions] = useState(false); - - const hasListener = useRef(false); - - const isReactionEnabled = channelCapabilities['send-reaction']; - - const messageDeleted = !!message?.deleted_at; - - const closeDetailedReactions: EventListener = useCallback( - (event) => { - if ( - event.target instanceof HTMLElement && - reactionSelectorRef?.current?.contains(event.target) && - !closeReactionSelectorOnClick - ) { - return; - } - - setShowDetailedReactions(false); - }, - [closeReactionSelectorOnClick, setShowDetailedReactions, reactionSelectorRef], - ); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (showDetailedReactions && !hasListener.current) { - hasListener.current = true; - document.addEventListener('click', closeDetailedReactions); - messageWrapper?.addEventListener('mouseleave', closeDetailedReactions); - } - - if (!showDetailedReactions && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - messageWrapper?.removeEventListener('mouseleave', closeDetailedReactions); - - hasListener.current = false; - } - - return () => { - if (hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }; - }, [showDetailedReactions, closeDetailedReactions, messageWrapperRef]); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (messageDeleted && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }, [messageDeleted, closeDetailedReactions, messageWrapperRef]); - - const onReactionListClick: ReactEventHandler = (event) => { - event?.stopPropagation?.(); - setShowDetailedReactions((prev) => !prev); - }; - - return { - isReactionEnabled, - onReactionListClick, - showDetailedReactions, - }; -}; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 9d659e4f8b..26e6a8c238 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -314,6 +314,10 @@ export const areMessagePropsEqual = < return false; } + if (nextProps.closeReactionSelectorOnClick !== prevProps.closeReactionSelectorOnClick) { + return false; + } + const messagesAreEqual = areMessagesEqual(prevMessage, nextMessage); if (!messagesAreEqual) return false; diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index d8fa2b53d6..9f2d9475b6 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import { isMutableRef } from './utils/utils'; - import { AvatarProps, Avatar as DefaultAvatar } from '../Avatar'; +import { useDialog } from '../Dialog'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; @@ -12,6 +11,7 @@ import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; +import { isMutableRef } from './utils/utils'; export type ReactionSelectorProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -39,181 +39,191 @@ export type ReactionSelectorProps< reverse?: boolean; }; -const UnMemoizedReactionSelector = React.forwardRef( - ( - props: ReactionSelectorProps, - ref: React.ForwardedRef, - ) => { - const { - Avatar: propAvatar, - detailedView = true, - handleReaction: propHandleReaction, - latest_reactions: propLatestReactions, - own_reactions: propOwnReactions, - reaction_groups: propReactionGroups, - reactionOptions: propReactionOptions, - reverse = false, - } = props; - - const { - Avatar: contextAvatar, - reactionOptions: contextReactionOptions, - } = useComponentContext('ReactionSelector'); - const { - handleReaction: contextHandleReaction, - message, - } = useMessageContext('ReactionSelector'); - - const reactionOptions = propReactionOptions ?? contextReactionOptions; - - const Avatar = propAvatar || contextAvatar || DefaultAvatar; - const handleReaction = propHandleReaction || contextHandleReaction; - const latestReactions = propLatestReactions || message?.latest_reactions || []; - const ownReactions = propOwnReactions || message?.own_reactions || []; - const reactionGroups = propReactionGroups || message?.reaction_groups || {}; - - const [tooltipReactionType, setTooltipReactionType] = useState(null); - const [tooltipPositions, setTooltipPositions] = useState<{ - arrow: number; - tooltip: number; - } | null>(null); - - const targetRef = useRef(null); - const tooltipRef = useRef(null); - - const showTooltip = useCallback( - (event: React.MouseEvent, reactionType: string) => { - targetRef.current = event.currentTarget; - setTooltipReactionType(reactionType); - }, - [], - ); - - const hideTooltip = useCallback(() => { - setTooltipReactionType(null); - setTooltipPositions(null); - }, []); - - useEffect(() => { - if (tooltipReactionType) { - const tooltip = tooltipRef.current?.getBoundingClientRect(); - const target = targetRef.current?.getBoundingClientRect(); - - const container = isMutableRef(ref) ? ref.current?.getBoundingClientRect() : null; - - if (!tooltip || !target || !container) return; - - const tooltipPosition = - tooltip.width === container.width || tooltip.x < container.x - ? 0 - : target.left + target.width / 2 - container.left - tooltip.width / 2; - - const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; - - setTooltipPositions({ - arrow: arrowPosition, - tooltip: tooltipPosition, - }); - } - }, [tooltipReactionType, ref]); - - const getUsersPerReactionType = (type: string | null) => - latestReactions - .map((reaction) => { - if (reaction.type === type) { - return reaction.user?.name || reaction.user?.id; - } - return null; - }) - .filter(Boolean); - - const iHaveReactedWithReaction = (reactionType: string) => - ownReactions.find((reaction) => reaction.type === reactionType); - - const getLatestUserForReactionType = (type: string | null) => - latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || - undefined; - - return ( -
( + props: ReactionSelectorProps, +) => { + const { + Avatar: propAvatar, + detailedView = true, + handleReaction: propHandleReaction, + latest_reactions: propLatestReactions, + own_reactions: propOwnReactions, + reaction_groups: propReactionGroups, + reactionOptions: propReactionOptions, + reverse = false, + } = props; + + const { + Avatar: contextAvatar, + reactionOptions: contextReactionOptions, + } = useComponentContext('ReactionSelector'); + const { + closeReactionSelectorOnClick, + handleReaction: contextHandleReaction, + message, + } = useMessageContext('ReactionSelector'); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const reactionOptions = propReactionOptions ?? contextReactionOptions; + + const Avatar = propAvatar || contextAvatar || DefaultAvatar; + const handleReaction = propHandleReaction || contextHandleReaction; + const latestReactions = propLatestReactions || message?.latest_reactions || []; + const ownReactions = propOwnReactions || message?.own_reactions || []; + const reactionGroups = propReactionGroups || message?.reaction_groups || {}; + + const [tooltipReactionType, setTooltipReactionType] = useState(null); + const [tooltipPositions, setTooltipPositions] = useState<{ + arrow: number; + tooltip: number; + } | null>(null); + + const rootRef = useRef(null); + const targetRef = useRef(null); + const tooltipRef = useRef(null); + + const showTooltip = useCallback( + (event: React.MouseEvent, reactionType: string) => { + targetRef.current = event.currentTarget; + setTooltipReactionType(reactionType); + }, + [], + ); + + const hideTooltip = useCallback(() => { + setTooltipReactionType(null); + setTooltipPositions(null); + }, []); + + useEffect(() => { + if (!tooltipReactionType || !rootRef.current) return; + const tooltip = tooltipRef.current?.getBoundingClientRect(); + const target = targetRef.current?.getBoundingClientRect(); + + const container = isMutableRef(rootRef) ? rootRef.current?.getBoundingClientRect() : null; + + if (!tooltip || !target || !container) return; + + const tooltipPosition = + tooltip.width === container.width || tooltip.x < container.x + ? 0 + : target.left + target.width / 2 - container.left - tooltip.width / 2; + + const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; + + setTooltipPositions({ + arrow: arrowPosition, + tooltip: tooltipPosition, + }); + }, [tooltipReactionType, rootRef]); + + const getUsersPerReactionType = (type: string | null) => + latestReactions + .map((reaction) => { + if (reaction.type === type) { + return reaction.user?.name || reaction.user?.id; + } + return null; + }) + .filter(Boolean); + + const iHaveReactedWithReaction = (reactionType: string) => + ownReactions.find((reaction) => reaction.type === reactionType); + + const getLatestUserForReactionType = (type: string | null) => + latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || + undefined; + + return ( +
- {!!tooltipReactionType && detailedView && ( -
-
- {getUsersPerReactionType(tooltipReactionType)?.map((user, i, users) => ( - - {`${user}${i < users.length - 1 ? ', ' : ''}`} - - ))} -
- )} -
    - {reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => { - const latestUser = getLatestUserForReactionType(reactionType); - const count = reactionGroups[reactionType]?.count ?? 0; - return ( -
  • - -
  • - ); - })} -
-
- ); - }, -); + )} + + + ); + })} + +
+ ); +}; /** * Component that allows a user to select a reaction. diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx new file mode 100644 index 0000000000..5a083e7d22 --- /dev/null +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -0,0 +1,54 @@ +import React, { ElementRef, useRef } from 'react'; +import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; +import { useComponentContext, useMessageContext, useTranslationContext } from '../../context'; +import type { DefaultStreamChatGenerics } from '../../types'; +import type { IconProps } from '../../types/types'; + +type ReactionSelectorWithButtonProps = { + /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ + ReactionIcon: React.ComponentType; + /* Theme string to be added to CSS class names. */ + theme: string; +}; + +/** + * Internal convenience component - not to be exported. It just groups the button and the dialog anchor and thus prevents + * cluttering the parent component. + */ +export const ReactionSelectorWithButton = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + ReactionIcon, + theme, +}: ReactionSelectorWithButtonProps) => { + const { t } = useTranslationContext('ReactionSelectorWithButton'); + const { isMyMessage, message } = useMessageContext('MessageOptions'); + const { ReactionSelector = DefaultReactionSelector } = useComponentContext('MessageOptions'); + const buttonRef = useRef>(null); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId); + return ( + <> + + + + + + ); +}; diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js index 500d9d9e4c..0c159777e7 100644 --- a/src/components/Reactions/__tests__/ReactionSelector.test.js +++ b/src/components/Reactions/__tests__/ReactionSelector.test.js @@ -13,8 +13,9 @@ import { Avatar as AvatarMock } from '../../Avatar'; import { ComponentProvider } from '../../../context/ComponentContext'; import { MessageProvider } from '../../../context/MessageContext'; +import { DialogsManagerProvider } from '../../../context'; -import { generateReaction, generateUser } from '../../../mock-builders'; +import { generateMessage, generateReaction, generateUser } from '../../../mock-builders'; jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() =>
), @@ -35,11 +36,13 @@ const handleReactionMock = jest.fn(); const renderComponent = (props) => render( - - - - - , + + + + + + + , ); describe('ReactionSelector', () => { diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 77ac4ed9bc..7a602ad5b2 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -70,10 +70,6 @@ export type MessageContextValue< handleRetry: ChannelActionContextValue['retrySendMessage']; /** Function that returns whether the Message belongs to the current user */ isMyMessage: () => boolean; - /** @deprecated will be removed in the next major release. - * Whether sending reactions is enabled for the active channel. - */ - isReactionEnabled: boolean; /** The message object */ message: StreamMessage; /** Indicates whether a message has not been read yet or has been marked unread */ @@ -82,22 +78,18 @@ export type MessageContextValue< onMentionsClickMessage: ReactEventHandler; /** Handler function for a hover event on an @mention in Message */ onMentionsHoverMessage: ReactEventHandler; - /** Handler function for a click event on the reaction list */ - onReactionListClick: ReactEventHandler; /** Handler function for a click event on the user that posted the Message */ onUserClick: ReactEventHandler; /** Handler function for a hover event on the user that posted the Message */ onUserHover: ReactEventHandler; - /** Ref to be placed on the reaction selector component */ - reactionSelectorRef: React.MutableRefObject; /** Function to toggle the edit state on a Message */ setEditingState: ReactEventHandler; - /** Whether or not to show reaction list details */ - showDetailedReactions: boolean; /** Additional props for underlying MessageInput component, [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ additionalMessageInputProps?: MessageInputProps; /** Call this function to keep message list scrolled to the bottom when the scroll height increases, e.g. an element appears below the last message (only used in the `VirtualizedMessageList`) */ autoscrollToBottom?: () => void; + /** Message component configuration prop. If true, picking a reaction from the `ReactionSelector` component will close the selector */ + closeReactionSelectorOnClick?: boolean; /** Object containing custom message actions and function handlers */ customMessageActions?: CustomMessageActions; /** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */