From 78af31e465dd85769d498b1d0700323fcc263652 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 9 Jan 2026 18:20:23 +0100 Subject: [PATCH 1/3] Move MessageActions and related functinality from experimental --- src/components/Channel/Channel.tsx | 6 - src/components/Message/FixedHeightMessage.tsx | 19 +- src/components/Message/MessageOptions.tsx | 112 -------- src/components/Message/MessageSimple.tsx | 9 +- src/components/Message/index.ts | 1 - src/components/Message/utils.tsx | 60 +--- .../CustomMessageActionsList.tsx | 37 --- .../MessageActions/MessageActions.tsx | 264 ++++++++---------- .../MessageActions/MessageActionsBox.tsx | 203 -------------- .../MessageActions/MessageActionsWrapper.tsx | 30 ++ .../MessageActions/defaults.tsx | 0 .../MessageActions/hooks/index.ts | 0 .../hooks/useBaseMessageActionSetFilter.ts | 0 .../hooks/useSplitMessageActionSet.ts | 0 src/components/MessageActions/index.ts | 4 +- src/context/ComponentContext.tsx | 10 - .../MessageActions/MessageActions.tsx | 127 --------- src/experimental/MessageActions/index.ts | 3 - src/experimental/index.ts | 1 - 19 files changed, 149 insertions(+), 737 deletions(-) delete mode 100644 src/components/Message/MessageOptions.tsx delete mode 100644 src/components/MessageActions/CustomMessageActionsList.tsx delete mode 100644 src/components/MessageActions/MessageActionsBox.tsx create mode 100644 src/components/MessageActions/MessageActionsWrapper.tsx rename src/{experimental => components}/MessageActions/defaults.tsx (100%) rename src/{experimental => components}/MessageActions/hooks/index.ts (100%) rename src/{experimental => components}/MessageActions/hooks/useBaseMessageActionSetFilter.ts (100%) rename src/{experimental => components}/MessageActions/hooks/useSplitMessageActionSet.ts (100%) delete mode 100644 src/experimental/MessageActions/MessageActions.tsx delete mode 100644 src/experimental/MessageActions/index.ts diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 32fc8fe0fb..b81a465e5f 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -105,7 +105,6 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'Avatar' | 'BaseImage' | 'CooldownTimer' - | 'CustomMessageActionsList' | 'DateSeparator' | 'EditMessageInput' | 'EditMessageModal' @@ -128,7 +127,6 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'MessageListNotifications' | 'MessageListMainPanel' | 'MessageNotification' - | 'MessageOptions' | 'MessageRepliesCountButton' | 'MessageStatus' | 'MessageSystem' @@ -1216,7 +1214,6 @@ const ChannelInner = ( Avatar: props.Avatar, BaseImage: props.BaseImage, CooldownTimer: props.CooldownTimer, - CustomMessageActionsList: props.CustomMessageActionsList, DateSeparator: props.DateSeparator, EditMessageInput: props.EditMessageInput, EditMessageModal: props.EditMessageModal, @@ -1238,7 +1235,6 @@ const ChannelInner = ( props.MessageIsThreadReplyInChannelButtonIndicator, MessageListNotifications: props.MessageListNotifications, MessageNotification: props.MessageNotification, - MessageOptions: props.MessageOptions, MessageRepliesCountButton: props.MessageRepliesCountButton, MessageStatus: props.MessageStatus, MessageSystem: props.MessageSystem, @@ -1286,7 +1282,6 @@ const ChannelInner = ( props.Avatar, props.BaseImage, props.CooldownTimer, - props.CustomMessageActionsList, props.DateSeparator, props.EditMessageInput, props.EditMessageModal, @@ -1307,7 +1302,6 @@ const ChannelInner = ( props.MessageIsThreadReplyInChannelButtonIndicator, props.MessageListNotifications, props.MessageNotification, - props.MessageOptions, props.MessageRepliesCountButton, props.MessageStatus, props.MessageSystem, diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx index 9ee2801ebb..eab271569b 100644 --- a/src/components/Message/FixedHeightMessage.tsx +++ b/src/components/Message/FixedHeightMessage.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; -import { useDeleteHandler, useUserRole } from './hooks'; +import { useUserRole } from './hooks'; import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted'; import { MessageTimestamp } from './MessageTimestamp'; -import { getMessageActions } from './utils'; import { Avatar } from '../Avatar'; import { Gallery } from '../Gallery'; @@ -55,7 +54,6 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => { propGroupedByUser !== undefined ? propGroupedByUser : contextGroupedByUser; const message = propMessage || contextMessage; - const handleDelete = useDeleteHandler(message); const role = useUserRole(message); const messageTextToRender = @@ -70,11 +68,6 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => { const userId = message.user?.id || ''; const userColor = useMemo(() => getUserColor(theme, userId), [userId, theme]); - const messageActionsHandler = useCallback( - () => getMessageActions(['delete'], { canDelete: role.canDelete }), - [role], - ); - const images = message?.attachments?.filter(({ type }) => type === 'image'); return ( @@ -105,13 +98,7 @@ const UnMemoizedFixedHeightMessage = (props: FixedHeightMessageProps) => {
{renderedText}
- role.isMyMessage} - /> + -> & { - /* Custom component rendering the icon used in message actions button. This button invokes the message actions menu. */ - ActionsIcon?: React.ComponentType; - /* If true, show the `ThreadIcon` and enable navigation into a `Thread` component. */ - displayReplies?: boolean; - /* 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; - /* Custom component rendering the icon used in a message options button opening thread */ - ThreadIcon?: React.ComponentType; -}; - -const UnMemoizedMessageOptions = (props: MessageOptionsProps) => { - const { - ActionsIcon = DefaultActionsIcon, - displayReplies = true, - handleOpenThread: propHandleOpenThread, - ReactionIcon = DefaultReactionIcon, - theme = 'simple', - ThreadIcon = DefaultThreadIcon, - } = props; - - const { - getMessageActions, - handleOpenThread: contextHandleOpenThread, - initialMessage, - message, - threadList, - } = useMessageContext('MessageOptions'); - - const { t } = useTranslationContext('MessageOptions'); - - // It is necessary to namespace the dialog IDs because a message with the same ID - // can appear in the main message list as well as in the thread message list. - // Without the namespace, the search for dialog would be performed by the message ID only - // which could return the dialog for a message in another message list (which would not be rendered). - const dialogIdNamespace = threadList ? '-thread-' : ''; - - const messageActionsDialogIsOpen = useDialogIsOpen( - `message-actions${dialogIdNamespace}--${message.id}`, - ); - const reactionSelectorDialogIsOpen = useDialogIsOpen( - `reaction-selector${dialogIdNamespace}--${message.id}`, - ); - const handleOpenThread = propHandleOpenThread || contextHandleOpenThread; - - const messageActions = getMessageActions(); - - const shouldShowReactions = messageActions.indexOf(MESSAGE_ACTIONS.react) > -1; - const shouldShowReplies = - messageActions.indexOf(MESSAGE_ACTIONS.reply) > -1 && displayReplies && !threadList; - - if ( - !message.type || - message.type === 'error' || - message.type === 'system' || - message.type === 'ephemeral' || - message.status === 'failed' || - message.status === 'sending' || - initialMessage - ) { - return null; - } - - return ( -
- - {shouldShowReplies && ( - - )} - {shouldShowReactions && } -
- ); -}; - -export const MessageOptions = React.memo( - UnMemoizedMessageOptions, -) as typeof UnMemoizedMessageOptions; diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index 9bf01d1666..b7c38cd571 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -5,7 +5,7 @@ import { MessageErrorIcon } from './icons'; import { MessageBouncePrompt as DefaultMessageBouncePrompt } from '../MessageBounce'; import { MessageDeleted as DefaultMessageDeleted } from './MessageDeleted'; import { MessageBlocked as DefaultMessageBlocked } from './MessageBlocked'; -import { MessageOptions as DefaultMessageOptions } from './MessageOptions'; +import { MessageActions as DefaultMessageActions } from '../MessageActions'; import { MessageRepliesCountButton as DefaultMessageRepliesCountButton } from './MessageRepliesCountButton'; import { MessageStatus as DefaultMessageStatus } from './MessageStatus'; import { MessageText } from './MessageText'; @@ -70,10 +70,7 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { Attachment = DefaultAttachment, Avatar = DefaultAvatar, EditMessageModal = DefaultEditMessageModal, - MessageOptions = DefaultMessageOptions, - // TODO: remove this "passthrough" in the next - // major release and use the new default instead - MessageActions = MessageOptions, + MessageActions = DefaultMessageActions, MessageBlocked = DefaultMessageBlocked, MessageBouncePrompt = DefaultMessageBouncePrompt, MessageDeleted = DefaultMessageDeleted, @@ -81,10 +78,10 @@ const MessageSimpleWithContext = (props: MessageSimpleWithContextProps) => { MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, + PinIndicator, ReactionsList = DefaultReactionList, ReminderNotification = DefaultReminderNotification, StreamedMessageText = DefaultStreamedMessageText, - PinIndicator, } = useComponentContext('MessageSimple'); const hasAttachment = messageHasAttachments(message); const hasReactions = messageHasReactions(message); diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts index 3509ee6b01..eb848343e2 100644 --- a/src/components/Message/index.ts +++ b/src/components/Message/index.ts @@ -6,7 +6,6 @@ export * from './MessageBlocked'; export * from './MessageDeleted'; export * from './MessageEditedTimestamp'; export * from './MessageIsThreadReplyInChannelButtonIndicator'; -export * from './MessageOptions'; export * from './MessageRepliesCountButton'; export * from './MessageSimple'; export * from './MessageStatus'; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 5a1974d6be..fddf841688 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -13,11 +13,7 @@ import type { } from 'stream-chat'; import type { PinPermissions } from './hooks'; import type { MessageProps } from './types'; -import type { - ComponentContextValue, - CustomMessageActions, - MessageContextValue, -} from '../../context'; +import type { MessageContextValue } from '../../context'; /** * Following function validates a function which returns notification message. @@ -235,60 +231,6 @@ export const ACTIONS_NOT_WORKING_IN_THREAD = [ MESSAGE_ACTIONS.markUnread, ]; -/** - * @deprecated use `shouldRenderMessageActions` instead - */ -export const showMessageActionsBox = ( - actions: MessageActionsArray, - inThread?: boolean | undefined, -) => shouldRenderMessageActions({ inThread, messageActions: actions }); - -export const shouldRenderMessageActions = ({ - customMessageActions, - CustomMessageActionsList, - inThread, - messageActions, -}: { - messageActions: MessageActionsArray; - customMessageActions?: CustomMessageActions; - CustomMessageActionsList?: ComponentContextValue['CustomMessageActionsList']; - inThread?: boolean; -}) => { - if ( - typeof CustomMessageActionsList !== 'undefined' || - typeof customMessageActions !== 'undefined' - ) - return true; - - if (!messageActions.length) return false; - - if ( - inThread && - messageActions.filter((action) => !ACTIONS_NOT_WORKING_IN_THREAD.includes(action)) - .length === 0 - ) { - return false; - } - - if ( - messageActions.length === 1 && - (messageActions.includes(MESSAGE_ACTIONS.react) || - messageActions.includes(MESSAGE_ACTIONS.reply)) - ) { - return false; - } - - if ( - messageActions.length === 2 && - messageActions.includes(MESSAGE_ACTIONS.react) && - messageActions.includes(MESSAGE_ACTIONS.reply) - ) { - return false; - } - - return true; -}; - function areMessagesEqual(prevMessage: LocalMessage, nextMessage: LocalMessage): boolean { const areBaseMessagesEqual = ( prevMessage: LocalMessageBase, diff --git a/src/components/MessageActions/CustomMessageActionsList.tsx b/src/components/MessageActions/CustomMessageActionsList.tsx deleted file mode 100644 index 969439d093..0000000000 --- a/src/components/MessageActions/CustomMessageActionsList.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; - -import type { LocalMessage } from 'stream-chat'; -import type { CustomMessageActions } from '../../context/MessageContext'; - -export type CustomMessageActionsListProps = { - message: LocalMessage; - customMessageActions?: CustomMessageActions; -}; - -export const CustomMessageActionsList = (props: CustomMessageActionsListProps) => { - const { customMessageActions, message } = props; - - if (!customMessageActions) return null; - - const customActionsArray = Object.keys(customMessageActions); - - return ( - <> - {customActionsArray.map((customAction) => { - const customHandler = customMessageActions[customAction]; - - return ( - - ); - })} - - ); -}; diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index c3db70ca8b..d7b46a9c4c 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -1,171 +1,127 @@ import clsx from 'clsx'; +import React, { useState } from 'react'; import type { PropsWithChildren } from 'react'; -import React, { useCallback, useRef } from 'react'; -import { MessageActionsBox } from './MessageActionsBox'; - -import { DialogAnchor, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; -import { ActionsIcon as DefaultActionsIcon } from '../Message/icons'; -import { isUserMuted, shouldRenderMessageActions } from '../Message/utils'; - -import { useChatContext } from '../../context/ChatContext'; -import type { MessageContextValue } from '../../context/MessageContext'; -import { useMessageContext } from '../../context/MessageContext'; -import { useComponentContext, useTranslationContext } from '../../context'; - -import type { IconProps } from '../../types/types'; - -type MessageContextPropsToPick = - | 'getMessageActions' - | 'handleDelete' - | 'handleFlag' - | 'handleMarkUnread' - | 'handleMute' - | 'handlePin' - | 'message'; - -export type MessageActionsProps = Partial< - Pick -> & { - /* Custom component rendering the icon used in message actions button. This button invokes the message actions menu. */ - ActionsIcon?: React.ComponentType; - /* Custom CSS class to be added to the `div` wrapping the component */ - customWrapperClass?: string; - /* If true, renders the wrapper component as a `span`, not a `div` */ - inline?: boolean; - /* Function that returns whether the message was sent by the connected user */ - mine?: () => boolean; +import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; +import { ActionsIcon } from '../../components/Message/icons'; +import { + DialogAnchor, + useDialogIsOpen, + useDialogOnNearestManager, +} from '../../components/Dialog'; +import { MessageActionsWrapper } from './MessageActionsWrapper'; +import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks'; +import { defaultMessageActionSet } from './defaults'; +import type { MESSAGE_ACTIONS } from '../Message/utils'; + +export type MessageActionSetItem = { + Component: React.ComponentType; + placement: 'quick' | 'dropdown'; + type: keyof typeof MESSAGE_ACTIONS | (string & {}); }; -export const MessageActions = (props: MessageActionsProps) => { - const { - ActionsIcon = DefaultActionsIcon, - customWrapperClass = '', - getMessageActions: propGetMessageActions, - handleDelete: propHandleDelete, - handleFlag: propHandleFlag, - handleMarkUnread: propHandleMarkUnread, - handleMute: propHandleMute, - handlePin: propHandlePin, - inline, - message: propMessage, - mine, - } = props; - - const { mutes } = useChatContext('MessageActions'); - - const { - customMessageActions, - getMessageActions: contextGetMessageActions, - handleDelete: contextHandleDelete, - handleFlag: contextHandleFlag, - handleMarkUnread: contextHandleMarkUnread, - handleMute: contextHandleMute, - handlePin: contextHandlePin, - isMyMessage, - message: contextMessage, - setEditingState, - threadList, - } = useMessageContext('MessageActions'); - - const { CustomMessageActionsList } = useComponentContext('MessageActions'); - - const { t } = useTranslationContext('MessageActions'); - - const getMessageActions = propGetMessageActions || contextGetMessageActions; - const handleDelete = propHandleDelete || contextHandleDelete; - const handleFlag = propHandleFlag || contextHandleFlag; - const handleMarkUnread = propHandleMarkUnread || contextHandleMarkUnread; - const handleMute = propHandleMute || contextHandleMute; - const handlePin = propHandlePin || contextHandlePin; - const message = propMessage || contextMessage; - const isMine = mine ? mine() : isMyMessage(); - - const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]); - - const dialogIdNamespace = threadList ? '-thread-' : ''; - const dialogId = `message-actions${dialogIdNamespace}--${message.id}`; - const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId }); - const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager?.id); +export type MessageActionsProps = { + disableBaseMessageActionSetFilter?: boolean; + messageActionSet?: MessageActionSetItem[]; +}; - const messageActions = getMessageActions(); +// TODO: allow passing down customWrapperClass +/** + * A new actions component to replace current `MessageOptions` component. + * Exports from `stream-chat-react/experimental` __MIGHT__ change - use with caution + * and follow release notes in case you notice unexpected behavior. + */ +export const MessageActions = ({ + disableBaseMessageActionSetFilter = false, + messageActionSet = defaultMessageActionSet, +}: MessageActionsProps) => { + const { theme } = useChatContext(); + const { isMyMessage, message } = useMessageContext(); + const { t } = useTranslationContext(); + const [actionsBoxButtonElement, setActionsBoxButtonElement] = + useState(null); + + const filteredMessageActionSet = useBaseMessageActionSetFilter( + messageActionSet, + disableBaseMessageActionSetFilter, + ); - const renderMessageActions = shouldRenderMessageActions({ - customMessageActions, - CustomMessageActionsList, - inThread: threadList, - messageActions, - }); + const { dropdownActionSet, quickActionSet } = useSplitMessageActionSet( + filteredMessageActionSet, + ); - const actionsBoxButtonRef = useRef(null); + const dropdownDialogId = `message-actions--${message.id}`; + const reactionSelectorDialogId = `reaction-selector--${message.id}`; + const { dialog, dialogManager } = useDialogOnNearestManager({ id: dropdownDialogId }); + const dropdownDialogIsOpen = useDialogIsOpen(dropdownDialogId, dialogManager?.id); + const reactionSelectorDialogIsOpen = useDialogIsOpen( + reactionSelectorDialogId, + dialogManager?.id, + ); - if (!renderMessageActions) return null; + // do not render anything if total action count is zero + if (dropdownActionSet.length + quickActionSet.length === 0) { + return null; + } return ( - - - - - - + {dropdownActionSet.length > 0 && ( + + + + + + {dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => ( + + ))} + + + + )} + {quickActionSet.map(({ Component: QuickActionComponent, type }) => ( + + ))} +
); }; -export type MessageActionsWrapperProps = { - customWrapperClass?: string; - inline?: boolean; - toggleOpen?: () => void; -}; - -export const MessageActionsWrapper = ( - props: PropsWithChildren, -) => { - const { children, customWrapperClass, inline, toggleOpen } = props; - - const defaultWrapperClass = clsx( - 'str-chat__message-simple__actions__action', - 'str-chat__message-simple__actions__action--options', - 'str-chat__message-actions-container', +const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => { + const { t } = useTranslationContext(); + return ( +
+
+ {children} +
+
); - - const wrapperProps = { - className: customWrapperClass || defaultWrapperClass, - 'data-testid': 'message-actions', - onClick: toggleOpen, - }; - - if (inline) return {children}; - - return
{children}
; }; diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx deleted file mode 100644 index 0a6c4392b6..0000000000 --- a/src/components/MessageActions/MessageActionsBox.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import clsx from 'clsx'; -import type { ComponentProps } from 'react'; -import React from 'react'; -import { CustomMessageActionsList as DefaultCustomMessageActionsList } from './CustomMessageActionsList'; -import { RemindMeActionButton } from './RemindMeSubmenu'; -import { OPTIONAL_MESSAGE_ACTIONS, useMessageReminder } from '../Message'; -import { useMessageComposer } from '../MessageInput'; -import { - useChatContext, - useComponentContext, - useMessageContext, - useTranslationContext, -} from '../../context'; -import { MESSAGE_ACTIONS } from '../Message/utils'; -import type { MessageContextValue } from '../../context'; - -type PropsDrilledToMessageActionsBox = - | 'getMessageActions' - | 'handleDelete' - | 'handleEdit' - | 'handleMarkUnread' - | 'handleFlag' - | 'handleMute' - | 'handlePin'; - -export type MessageActionsBoxProps = Pick< - MessageContextValue, - PropsDrilledToMessageActionsBox -> & { - isUserMuted: () => boolean; - mine: boolean; - open: boolean; -} & ComponentProps<'div'>; - -const UnMemoizedMessageActionsBox = (props: MessageActionsBoxProps) => { - const { - className, - getMessageActions, - handleDelete, - handleEdit, - handleFlag, - handleMarkUnread, - handleMute, - handlePin, - isUserMuted, - mine, - open, - ...restDivProps - } = props; - - const { client } = useChatContext(); - const { CustomMessageActionsList = DefaultCustomMessageActionsList } = - useComponentContext('MessageActionsBox'); - const { customMessageActions, message, threadList } = - useMessageContext('MessageActionsBox'); - const { t } = useTranslationContext('MessageActionsBox'); - const messageComposer = useMessageComposer(); - const reminder = useMessageReminder(message.id); - - const messageActions = getMessageActions(); - - const handleQuote = () => { - messageComposer.setQuotedMessage(message); - - const elements = message.parent_id - ? document.querySelectorAll('.str-chat__thread .str-chat__textarea__textarea') - : document.getElementsByClassName('str-chat__textarea__textarea'); - const textarea = elements.item(0); - - if (textarea instanceof HTMLTextAreaElement) { - textarea.focus(); - } - }; - - const rootClassName = clsx('str-chat__message-actions-box', className, { - 'str-chat__message-actions-box--open': open, - }); - - const buttonClassName = - 'str-chat__message-actions-list-item str-chat__message-actions-list-item-button'; - - return ( -
-
- - {messageActions.indexOf(MESSAGE_ACTIONS.quote) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && - !threadList && - !!message.id && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && ( - - )} - {messageActions.indexOf(OPTIONAL_MESSAGE_ACTIONS.deleteForMe) > -1 && - !message.deleted_for_me && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.remindMe) > -1 && ( - - )} - {messageActions.indexOf(MESSAGE_ACTIONS.saveForLater) > -1 && ( - - )} -
-
- ); -}; - -/** - * A popup box that displays the available actions on a message, such as edit, delete, pin, etc. - */ -export const MessageActionsBox = React.memo( - UnMemoizedMessageActionsBox, -) as typeof UnMemoizedMessageActionsBox; diff --git a/src/components/MessageActions/MessageActionsWrapper.tsx b/src/components/MessageActions/MessageActionsWrapper.tsx new file mode 100644 index 0000000000..cefb2a6ab7 --- /dev/null +++ b/src/components/MessageActions/MessageActionsWrapper.tsx @@ -0,0 +1,30 @@ +import clsx from 'clsx'; +import type { PropsWithChildren } from 'react'; + +export type MessageActionsWrapperProps = { + customWrapperClass?: string; + inline?: boolean; + toggleOpen?: () => void; +}; + +export const MessageActionsWrapper = ( + props: PropsWithChildren, +) => { + const { children, customWrapperClass, inline, toggleOpen } = props; + + const defaultWrapperClass = clsx( + 'str-chat__message-simple__actions__action', + 'str-chat__message-simple__actions__action--options', + 'str-chat__message-actions-container', + ); + + const wrapperProps = { + className: customWrapperClass || defaultWrapperClass, + 'data-testid': 'message-actions', + onClick: toggleOpen, + }; + + if (inline) return {children}; + + return
{children}
; +}; diff --git a/src/experimental/MessageActions/defaults.tsx b/src/components/MessageActions/defaults.tsx similarity index 100% rename from src/experimental/MessageActions/defaults.tsx rename to src/components/MessageActions/defaults.tsx diff --git a/src/experimental/MessageActions/hooks/index.ts b/src/components/MessageActions/hooks/index.ts similarity index 100% rename from src/experimental/MessageActions/hooks/index.ts rename to src/components/MessageActions/hooks/index.ts diff --git a/src/experimental/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts similarity index 100% rename from src/experimental/MessageActions/hooks/useBaseMessageActionSetFilter.ts rename to src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts diff --git a/src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts b/src/components/MessageActions/hooks/useSplitMessageActionSet.ts similarity index 100% rename from src/experimental/MessageActions/hooks/useSplitMessageActionSet.ts rename to src/components/MessageActions/hooks/useSplitMessageActionSet.ts diff --git a/src/components/MessageActions/index.ts b/src/components/MessageActions/index.ts index 1790160d9b..1e0b5c79f0 100644 --- a/src/components/MessageActions/index.ts +++ b/src/components/MessageActions/index.ts @@ -1,3 +1,3 @@ export * from './MessageActions'; -export * from './MessageActionsBox'; -export * from './CustomMessageActionsList'; +export * from './defaults'; +export * from './hooks'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 40f9a7845b..7f75729b48 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -8,7 +8,6 @@ import type { BaseImageProps, ChannelPreviewActionButtonsProps, CooldownTimerProps, - CustomMessageActionsListProps, DateSeparatorProps, EditMessageModalProps, EmojiSearchIndex, @@ -22,7 +21,6 @@ import type { MessageInputProps, MessageListNotificationsProps, MessageNotificationProps, - MessageOptionsProps, MessageProps, MessageRepliesCountButtonProps, MessageStatusProps, @@ -91,8 +89,6 @@ export type ComponentContextValue = { ChannelPreviewActionButtons?: React.ComponentType; /** Custom UI component to display the slow mode cooldown timer, defaults to and accepts same props as: [CooldownTimer](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/CooldownTimer.tsx) */ CooldownTimer?: React.ComponentType; - /** Custom UI component to render set of buttons to be displayed in the MessageActionsBox, defaults to and accepts same props as: [CustomMessageActionsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageActions/CustomMessageActionsList.tsx) */ - CustomMessageActionsList?: React.ComponentType; /** Custom UI component for date separators, defaults to and accepts same props as: [DateSeparator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/DateSeparator.tsx) */ DateSeparator?: React.ComponentType; /** Custom UI component to override default edit message input, defaults to and accepts same props as: [EditMessageForm](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageInput/EditMessageForm.tsx) */ @@ -137,12 +133,6 @@ export type ComponentContextValue = { MessageListNotifications?: React.ComponentType; /** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [MessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/MessageNotification.tsx) */ MessageNotification?: React.ComponentType; - /** - * Custom UI component for message options popup, defaults to and accepts same props as: [MessageOptions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageOptions.tsx) - * - * @deprecated Use MessageActions property instead. - */ - MessageOptions?: React.ComponentType; /** Custom UI component to display message replies, defaults to and accepts same props as: [MessageRepliesCountButton](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageRepliesCountButton.tsx) */ MessageRepliesCountButton?: React.ComponentType; /** Custom UI component to display message delivery status, defaults to and accepts same props as: [MessageStatus](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageStatus.tsx) */ diff --git a/src/experimental/MessageActions/MessageActions.tsx b/src/experimental/MessageActions/MessageActions.tsx deleted file mode 100644 index 2ce3517cc9..0000000000 --- a/src/experimental/MessageActions/MessageActions.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import clsx from 'clsx'; -import React, { useState } from 'react'; -import type { PropsWithChildren } from 'react'; - -import { useChatContext, useMessageContext, useTranslationContext } from '../../context'; -import { ActionsIcon } from '../../components/Message/icons'; -import { - DialogAnchor, - useDialogIsOpen, - useDialogOnNearestManager, -} from '../../components/Dialog'; -import { MessageActionsWrapper } from '../../components/MessageActions/MessageActions'; -import { useBaseMessageActionSetFilter, useSplitMessageActionSet } from './hooks'; -import { defaultMessageActionSet } from './defaults'; -import type { MESSAGE_ACTIONS } from '../../components'; - -export type MessageActionSetItem = { - Component: React.ComponentType; - placement: 'quick' | 'dropdown'; - type: keyof typeof MESSAGE_ACTIONS | (string & {}); -}; - -export type MessageActionsProps = { - disableBaseMessageActionSetFilter?: boolean; - messageActionSet?: MessageActionSetItem[]; -}; - -// TODO: allow passing down customWrapperClass -/** - * A new actions component to replace current `MessageOptions` component. - * Exports from `stream-chat-react/experimental` __MIGHT__ change - use with caution - * and follow release notes in case you notice unexpected behavior. - */ -export const MessageActions = ({ - disableBaseMessageActionSetFilter = false, - messageActionSet = defaultMessageActionSet, -}: MessageActionsProps) => { - const { theme } = useChatContext(); - const { isMyMessage, message } = useMessageContext(); - const { t } = useTranslationContext(); - const [actionsBoxButtonElement, setActionsBoxButtonElement] = - useState(null); - - const filteredMessageActionSet = useBaseMessageActionSetFilter( - messageActionSet, - disableBaseMessageActionSetFilter, - ); - - const { dropdownActionSet, quickActionSet } = useSplitMessageActionSet( - filteredMessageActionSet, - ); - - const dropdownDialogId = `message-actions--${message.id}`; - const reactionSelectorDialogId = `reaction-selector--${message.id}`; - const { dialog, dialogManager } = useDialogOnNearestManager({ id: dropdownDialogId }); - const dropdownDialogIsOpen = useDialogIsOpen(dropdownDialogId, dialogManager?.id); - const reactionSelectorDialogIsOpen = useDialogIsOpen( - reactionSelectorDialogId, - dialogManager?.id, - ); - - // do not render anything if total action count is zero - if (dropdownActionSet.length + quickActionSet.length === 0) { - return null; - } - - return ( -
- {dropdownActionSet.length > 0 && ( - - - - - - {dropdownActionSet.map(({ Component: DropdownActionComponent, type }) => ( - - ))} - - - - )} - {quickActionSet.map(({ Component: QuickActionComponent, type }) => ( - - ))} -
- ); -}; - -const DropdownBox = ({ children, open }: PropsWithChildren<{ open: boolean }>) => { - const { t } = useTranslationContext(); - return ( -
-
- {children} -
-
- ); -}; diff --git a/src/experimental/MessageActions/index.ts b/src/experimental/MessageActions/index.ts deleted file mode 100644 index 1e0b5c79f0..0000000000 --- a/src/experimental/MessageActions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './MessageActions'; -export * from './defaults'; -export * from './hooks'; diff --git a/src/experimental/index.ts b/src/experimental/index.ts index 3941332373..addd53308b 100644 --- a/src/experimental/index.ts +++ b/src/experimental/index.ts @@ -1,2 +1 @@ -export * from './MessageActions'; export * from './Search'; From ea10e07b40cb10e6b14de317febb71536efe5434 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 9 Jan 2026 18:32:18 +0100 Subject: [PATCH 2/3] Remove customMessageActions related props --- src/components/Message/Message.tsx | 1 - src/components/Message/types.ts | 2 -- src/components/MessageList/MessageList.tsx | 2 -- src/components/MessageList/VirtualizedMessageList.tsx | 3 --- .../MessageList/VirtualizedMessageListComponents.tsx | 2 -- src/context/MessageContext.tsx | 9 --------- 6 files changed, 19 deletions(-) diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 39b0e3f039..8d07581982 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -268,7 +268,6 @@ export const Message = (props: MessageProps) => { autoscrollToBottom={props.autoscrollToBottom} canPin={canPin} closeReactionSelectorOnClick={closeReactionSelectorOnClick} - customMessageActions={props.customMessageActions} deliveredTo={props.deliveredTo} disableQuotedMessages={props.disableQuotedMessages} endOfGroup={props.endOfGroup} diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index c32669cb60..885fd14648 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -25,8 +25,6 @@ export type MessageProps = { autoscrollToBottom?: () => void; /** If true, picking a reaction from the `ReactionSelector` component will close the selector */ closeReactionSelectorOnClick?: boolean; - /** Object containing custom message actions and function handlers */ - customMessageActions?: MessageContextValue['customMessageActions']; /** An array of user IDs that have confirmed the message delivery to their device */ deliveredTo?: UserResponse[]; /** If true, disables the ability for users to quote messages, defaults to false */ diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index a6576d9e0b..0f0b996ee0 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -152,7 +152,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { internalMessageProps: { additionalMessageInputProps: props.additionalMessageInputProps, closeReactionSelectorOnClick: props.closeReactionSelectorOnClick, - customMessageActions: props.customMessageActions, disableQuotedMessages: props.disableQuotedMessages, formatDate: props.formatDate, getDeleteMessageErrorNotification: props.getDeleteMessageErrorNotification, @@ -298,7 +297,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { type PropsDrilledToMessage = | 'additionalMessageInputProps' | 'closeReactionSelectorOnClick' - | 'customMessageActions' | 'disableQuotedMessages' | 'formatDate' | 'getDeleteMessageErrorNotification' diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 3c4d6bc189..679087ae46 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -72,7 +72,6 @@ import { useLastOwnMessage } from './hooks/useLastOwnMessage'; type PropsDrilledToMessage = | 'additionalMessageInputProps' - | 'customMessageActions' | 'formatDate' | 'messageActions' | 'openThread' @@ -190,7 +189,6 @@ const VirtualizedMessageListWithContext = ( channel, channelUnreadUiState, closeReactionSelectorOnClick, - customMessageActions, customMessageRenderer, defaultItemHeight, disableDateSeparator = true, @@ -492,7 +490,6 @@ const VirtualizedMessageListWithContext = ( additionalMessageInputProps, closeReactionSelectorOnClick, customClasses, - customMessageActions, customMessageRenderer, DateSeparator, firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 7dcd433ea9..efa948f17d 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -111,7 +111,6 @@ export const messageRenderer = ( const { additionalMessageInputProps, closeReactionSelectorOnClick, - customMessageActions, customMessageRenderer, DateSeparator, firstUnreadMessageId, @@ -203,7 +202,6 @@ export const messageRenderer = ( additionalMessageInputProps={additionalMessageInputProps} autoscrollToBottom={virtuosoRef.current?.autoscrollToBottom} closeReactionSelectorOnClick={closeReactionSelectorOnClick} - customMessageActions={customMessageActions} deliveredTo={ownMessagesDeliveredToOthers[message.id] || []} endOfGroup={endOfGroup} firstOfGroup={firstOfGroup} diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index df4010e87e..a61a0a8046 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -27,13 +27,6 @@ import type { import type { RenderTextOptions } from '../components/Message/renderText'; import type { UnknownType } from '../types/types'; -export type CustomMessageActions = { - [key: string]: ( - message: LocalMessage, - event: React.BaseSyntheticEvent, - ) => Promise | void; -}; - export type MessageContextValue = { /** If actions such as edit, delete, flag, mute are enabled on Message */ actionsEnabled: boolean; @@ -99,8 +92,6 @@ export type MessageContextValue = { 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; /** An array of user IDs that have confirmed the message delivery to their device */ deliveredTo?: UserResponse[]; /** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ From 363e703b4f9e19474f30857ee04a8564c707e785 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 13 Jan 2026 19:44:35 +0100 Subject: [PATCH 3/3] Adjust MessageActions tests --- .../Channel/__tests__/Channel.test.js | 28 - .../__tests__/FixedHeightMessage.test.js | 27 +- .../Message/__tests__/MessageOptions.test.js | 412 ------- .../Message/__tests__/MessageSimple.test.js | 20 +- .../Message/__tests__/MessageText.test.js | 37 +- .../__snapshots__/MessageText.test.js.snap | 12 +- .../MessageActions/MessageActions.tsx | 1 + .../MessageActions/MessageActionsWrapper.tsx | 2 +- .../CustomMessageActionsList.test.js | 60 - .../__tests__/MessageActions.test.js | 1026 +++++++++++++---- .../__tests__/MessageActionsBox.test.js | 570 --------- .../hooks/useBaseMessageActionSetFilter.ts | 3 +- .../__tests__/MessageInput.test.js | 40 +- .../__tests__/ThreadMessageInput.test.js | 24 +- 14 files changed, 864 insertions(+), 1398 deletions(-) delete mode 100644 src/components/Message/__tests__/MessageOptions.test.js delete mode 100644 src/components/MessageActions/__tests__/CustomMessageActionsList.test.js delete mode 100644 src/components/MessageActions/__tests__/MessageActionsBox.test.js diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index db59877d03..9edc74e723 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -2273,32 +2273,4 @@ describe('Channel', () => { ); }); }); - - describe('Custom Components', () => { - it('should render CustomMessageActionsList if provided', async () => { - const CustomMessageActionsList = jest - .fn() - .mockImplementation(() => 'CustomMessageActionsList'); - - const messageContextValue = { - message: generateMessage(), - messageListRect: {}, - }; - - await renderComponent({ - channel, - chatClient, - children: ( - - [])} /> - - ), - CustomMessageActionsList, - }); - - await waitFor(() => { - expect(CustomMessageActionsList).toHaveBeenCalledTimes(1); - }); - }); - }); }); diff --git a/src/components/Message/__tests__/FixedHeightMessage.test.js b/src/components/Message/__tests__/FixedHeightMessage.test.js index b18c24de12..9823aa5a65 100644 --- a/src/components/Message/__tests__/FixedHeightMessage.test.js +++ b/src/components/Message/__tests__/FixedHeightMessage.test.js @@ -7,7 +7,6 @@ import { FixedHeightMessage } from '../FixedHeightMessage'; import { Avatar as AvatarMock } from '../../Avatar'; import { Gallery as GalleryMock } from '../../Gallery'; import { Message } from '../Message'; -import { MessageActions as MessageActionsMock } from '../../MessageActions'; import { ChannelActionProvider } from '../../../context/ChannelActionContext'; import { ChannelStateProvider } from '../../../context/ChannelStateContext'; @@ -20,17 +19,13 @@ import { generateUser, getTestClientWithUser, } from '../../../mock-builders'; -import { ComponentProvider } from '../../../context'; +import { ComponentProvider, DialogManagerProvider } from '../../../context'; jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() =>
) })); jest.mock('../../Gallery', () => ({ Gallery: jest.fn(() =>
) })); -jest.mock('../../MessageActions', () => ({ - MessageActions: jest.fn((props) => props.getMessageActions()), -})); const aliceProfile = { image: 'alice-avatar.jpg', name: 'alice' }; const alice = generateUser(aliceProfile); -const bob = generateUser({ name: 'bob' }); async function renderMsg(message) { const channel = generateChannel({ state: { membership: {} } }); @@ -57,7 +52,9 @@ async function renderMsg(message) { }} > - + + + @@ -93,22 +90,6 @@ describe('', () => { ); }); - it('should render message action for owner', async () => { - const message = generateMessage({ user: alice }); - await renderMsg(message); - expect(MessageActionsMock).toHaveBeenCalledWith( - expect.objectContaining({ message }), - undefined, - ); - expect(MessageActionsMock).toHaveReturnedWith(['delete']); - }); - - it('should not render message action for others', async () => { - const message = generateMessage({ user: bob }); - await renderMsg(message); - expect(MessageActionsMock).toHaveReturnedWith([]); - }); - it('should display text in users set language', async () => { const message = generateMessage({ i18n: { en_text: 'hello', fr_text: 'bonjour', language: 'fr' }, diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js deleted file mode 100644 index 96c91d1b78..0000000000 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ /dev/null @@ -1,412 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { Message } from '../Message'; -import { MessageOptions } from '../MessageOptions'; -import { MessageSimple } from '../MessageSimple'; -import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils'; - -import { Attachment } from '../../Attachment'; -import { defaultReactionOptions } from '../../Reactions'; - -import { - ChannelActionProvider, - ChannelStateProvider, - ChatProvider, - ComponentProvider, - DialogManagerProvider, -} from '../../../context'; - -import { - generateChannel, - generateMessage, - generateUser, - getTestClientWithUser, -} from '../../../mock-builders'; - -const MESSAGE_ACTIONS_TEST_ID = 'message-actions'; - -const minimumCapabilitiesToRenderMessageActions = { 'delete-any-message': true }; -const alice = generateUser({ name: 'alice' }); -const defaultMessageProps = { - initialMessage: false, - message: generateMessage(), - messageActions: Object.keys(MESSAGE_ACTIONS), - threadList: false, -}; -const defaultOptionsProps = {}; - -function generateAliceMessage(messageOptions) { - return generateMessage({ - user: alice, - ...messageOptions, - }); -} - -async function renderMessageOptions({ - channelConfig, - channelStateOpts = {}, - customMessageProps = {}, - customOptionsProps = {}, -}) { - const client = await getTestClientWithUser(alice); - const channel = generateChannel({ - getConfig: () => channelConfig, - state: { membership: {} }, - }); - - return render( - - - - - , - reactionOptions: defaultReactionOptions, - }} - > - - - - - - - - , - ); -} - -const threadActionTestId = 'thread-action'; -const reactionActionTestId = 'message-reaction-action'; - -describe('', () => { - beforeEach(jest.clearAllMocks); - it('should not render message options when there is no message set', async () => { - const { queryByTestId } = await renderMessageOptions({ - customMessageProps: { - message: {}, - }, - }); - expect(queryByTestId(/message-options/)).not.toBeInTheDocument(); - }); - - it.each([ - ['type', 'error'], - ['type', 'system'], - ['type', 'ephemeral'], - ['status', 'failed'], - ['status', 'sending'], - ])( - 'should not render message options when message is of %s %s and is from current user.', - async (key, value) => { - const message = generateAliceMessage({ [key]: value }); - const { queryByTestId } = await renderMessageOptions({ - customMessageProps: { message }, - }); - expect(queryByTestId(/message-options/)).not.toBeInTheDocument(); - }, - ); - - it('should not render message options when it is parent message in a thread', async () => { - const { queryByTestId } = await renderMessageOptions({ - customMessageProps: { - initialMessage: true, - }, - }); - expect(queryByTestId(/message-options/)).not.toBeInTheDocument(); - }); - - it('should display thread actions when message is not displayed in a thread list and channel has replies configured', async () => { - const { getByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: { 'send-reply': true }, - channelConfig: { replies: true }, - }, - customMessageProps: defaultMessageProps, - }); - expect(getByTestId(threadActionTestId)).toBeInTheDocument(); - }); - - it('should not display thread actions when message is in a thread list', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelConfig: { replies: true }, - customMessageProps: { threadList: true }, - }); - expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument(); - }); - - it('should not display thread actions when channel does not have replies enabled', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelConfig: { replies: false }, - }); - expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument(); - }); - - it('should trigger open thread handler when custom thread action is set and thread action is clicked', async () => { - const handleOpenThread = jest.fn(() => {}); - const message = generateMessage(); - const { getByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: { 'send-reply': true }, - channelConfig: { replies: true }, - }, - customMessageProps: { message, openThread: handleOpenThread, threadList: false }, - }); - expect(handleOpenThread).not.toHaveBeenCalled(); - fireEvent.click(getByTestId(threadActionTestId)); - - expect(handleOpenThread).toHaveBeenCalled(); - }); - - it('should display reactions action when channel has reactions enabled', async () => { - const { getByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: { 'send-reaction': true }, - }, - }); - expect(getByTestId(reactionActionTestId)).toBeInTheDocument(); - }); - - it('should not display reactions action when channel has reactions disabled', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: { 'send-reaction': false }, - }, - }); - 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, - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); - - it('should not show message actions button if actions are disabled', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { messageActions: [] }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should not show actions box for message in thread if only non-thread actions are available', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - messageActions: ACTIONS_NOT_WORKING_IN_THREAD, - threadList: true, - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should show actions box for message in thread if not only non-thread actions are available', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - messageActions: [...ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS.delete], - threadList: true, - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); - - it('should show actions box for a message in thread if custom actions provided are non-thread', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - customMessageActions: ACTIONS_NOT_WORKING_IN_THREAD, - messageActions: ACTIONS_NOT_WORKING_IN_THREAD, - threadList: true, - }, - }); - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); - - it('should not show actions box for message outside thread with single action "react"', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - messageActions: [MESSAGE_ACTIONS.react], - }, - }); - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should show actions box for message outside thread with single action "react" if custom actions available', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - customMessageActions: [MESSAGE_ACTIONS.react], - messageActions: [MESSAGE_ACTIONS.react], - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); - - it('should not show actions box for message outside thread with single action "reply"', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - messageActions: [MESSAGE_ACTIONS.reply], - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should show actions box for message outside thread with single action "reply" if custom actions available', async () => { - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - customMessageActions: [MESSAGE_ACTIONS.reply], - messageActions: [MESSAGE_ACTIONS.reply], - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); - - it('should not show actions box for message outside thread with two actions "react" & "reply"', async () => { - const actions = [MESSAGE_ACTIONS.react, MESSAGE_ACTIONS.reply]; - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - messageActions: actions, - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).not.toBeInTheDocument(); - }); - - it('should show actions box for message outside thread with single actions "react" & "reply" if custom actions available', async () => { - const actions = [MESSAGE_ACTIONS.react, MESSAGE_ACTIONS.reply]; - const { queryByTestId } = await renderMessageOptions({ - channelStateOpts: { - channelCapabilities: minimumCapabilitiesToRenderMessageActions, - }, - customMessageProps: { - customMessageActions: actions, - messageActions: actions, - }, - }); - - expect(queryByTestId(MESSAGE_ACTIONS_TEST_ID)).toBeInTheDocument(); - }); -}); diff --git a/src/components/Message/__tests__/MessageSimple.test.js b/src/components/Message/__tests__/MessageSimple.test.js index 83bd3c80e6..4267d457c0 100644 --- a/src/components/Message/__tests__/MessageSimple.test.js +++ b/src/components/Message/__tests__/MessageSimple.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import Dayjs from 'dayjs'; import calendar from 'dayjs/plugin/calendar'; @@ -7,7 +7,6 @@ import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; import { Message } from '../Message'; import { MessageSimple } from '../MessageSimple'; -import { MessageOptions as MessageOptionsMock } from '../MessageOptions'; import { MessageText as MessageTextMock } from '../MessageText'; import { MESSAGE_ACTIONS } from '../utils'; @@ -42,9 +41,6 @@ expect.extend(toHaveNoViolations); Dayjs.extend(calendar); -jest.mock('../MessageOptions', () => ({ - MessageOptions: jest.fn(() =>
), -})); jest.mock('../MessageText', () => ({ MessageText: jest.fn(() =>
), })); @@ -233,16 +229,16 @@ describe('', () => { expect(results).toHaveNoViolations(); }); - it('should render message with custom options component when one is given', async () => { + it('should render message with custom actions component when one is given', async () => { const message = generateAliceMessage({ text: '' }); - const CustomOptions = () =>
Options
; + const CustomActions = () =>
Actions
; const { container, getByTestId } = await renderMessageSimple({ components: { - MessageOptions: CustomOptions, + MessageActions: CustomActions, }, message, }); - expect(getByTestId('custom-message-options')).toBeInTheDocument(); + expect(getByTestId('custom-message-actions')).toBeInTheDocument(); const results = await axe(container); expect(results).toHaveNoViolations(); }); @@ -534,14 +530,16 @@ describe('', () => { it('should render message options', async () => { const message = generateAliceMessage({ text: undefined }); - const { container } = await renderMessageSimple({ + const { container, getByTestId } = await renderMessageSimple({ message, props: { handleOpenThread: jest.fn(), }, }); - expect(MessageOptionsMock).toHaveBeenCalled(); + await waitFor(() => { + expect(getByTestId('message-actions-host')).toBeInTheDocument(); + }); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js index a049f918b3..6df70b5071 100644 --- a/src/components/Message/__tests__/MessageText.test.js +++ b/src/components/Message/__tests__/MessageText.test.js @@ -9,6 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + DialogManagerProvider, TranslationProvider, } from '../../../context'; import { @@ -24,14 +25,13 @@ import { import { Attachment } from '../../Attachment'; import { defaultReactionOptions } from '../../Reactions'; import { Message } from '../Message'; -import { MessageOptions as MessageOptionsMock } from '../MessageOptions'; import { MessageSimple } from '../MessageSimple'; import { MessageText } from '../MessageText'; expect.extend(toHaveNoViolations); -jest.mock('../MessageOptions', () => ({ - MessageOptions: jest.fn(() =>
), +jest.mock('../../MessageActions', () => ({ + MessageActions: jest.fn(() =>
), })); const alice = generateUser({ name: 'alice' }); @@ -89,9 +89,11 @@ async function renderMessageText({ reactionOptions: defaultReactionOptions, }} > - - - + + + + + @@ -281,24 +283,11 @@ describe('', () => { expect(results).toHaveNoViolations(); }); - it('should render message options', async () => { - const { container } = await renderMessageText(); - expect(MessageOptionsMock).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should render message options with custom props when those are set', async () => { - const displayLeft = false; - const { container } = await renderMessageText({ - customProps: { - customOptionProps: { - displayLeft, - }, - }, - }); - - expect(MessageOptionsMock).toHaveBeenCalled(); + it('should render message actions', async () => { + const { container, getByTestId } = await renderMessageText(); + await waitFor(() => + expect(getByTestId('mocked-message-actions')).toBeInTheDocument(), + ); const results = await axe(container); expect(results).toHaveNoViolations(); }); diff --git a/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap b/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap index 590c69bfb2..04231ced6c 100644 --- a/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap +++ b/src/components/Message/__tests__/__snapshots__/MessageText.test.js.snap @@ -9,7 +9,9 @@ exports[` should render with a custom inner class when one is set class="str-chat__message-inner" data-testid="message-inner" > -
+
@@ -71,7 +73,9 @@ exports[` should render with a custom wrapper class when one is s class="str-chat__message-inner" data-testid="message-inner" > -
+
@@ -133,7 +137,9 @@ exports[` should render with custom theme identifier in generated class="str-chat__message-inner" data-testid="message-inner" > -
+
diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index d7b46a9c4c..3371fb92cb 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -70,6 +70,7 @@ export const MessageActions = ({ 'str-chat__message-options--active': dropdownDialogIsOpen || reactionSelectorDialogIsOpen, })} + data-testid='message-actions-host' > {dropdownActionSet.length > 0 && ( diff --git a/src/components/MessageActions/MessageActionsWrapper.tsx b/src/components/MessageActions/MessageActionsWrapper.tsx index cefb2a6ab7..44e67b8aed 100644 --- a/src/components/MessageActions/MessageActionsWrapper.tsx +++ b/src/components/MessageActions/MessageActionsWrapper.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import type { PropsWithChildren } from 'react'; +import React, { type PropsWithChildren } from 'react'; export type MessageActionsWrapperProps = { customWrapperClass?: string; diff --git a/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js b/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js deleted file mode 100644 index 36842c97aa..0000000000 --- a/src/components/MessageActions/__tests__/CustomMessageActionsList.test.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import { CustomMessageActionsList } from '../CustomMessageActionsList'; -import { act } from 'react'; - -describe('CustomMessageActionsList', () => { - it('should render custom list of actions', () => { - const message = { id: 'mId' }; - - const actions = { - key0: () => {}, - key1: () => {}, - }; - - const { container } = render( - , - ); - - expect(container).toMatchInlineSnapshot(` -
- - -
- `); - }); - - it('should allow clicking custom action', () => { - const message = { id: 'mId' }; - - const actions = { - key0: jest.fn(), - }; - - const { getByText } = render( - , - ); - - const button = getByText('key0'); - - const event = new Event('click', { bubbles: true }); - - act(() => { - fireEvent(button, event); - }); - - expect(actions.key0).toHaveBeenCalledWith(message, expect.any(Object)); // replacing SyntheticEvent with any(Object) - }); -}); diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 362213cc0a..ccef104770 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -1,11 +1,13 @@ import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { toHaveNoViolations } from 'jest-axe'; +import { axe } from '../../../../axe-helper'; import { MessageActions } from '../MessageActions'; -import { MessageActionsBox as MessageActionsBoxMock } from '../MessageActionsBox'; import { + ChannelActionProvider, ChannelStateProvider, ChatProvider, ComponentProvider, @@ -15,275 +17,845 @@ import { } from '../../../context'; import { + generateChannel, generateMessage, - getTestClient, + generateUser, + getTestClientWithUser, + initClientWithChannels, mockTranslationContext, } from '../../../mock-builders'; -jest.mock('../MessageActionsBox', () => ({ - MessageActionsBox: jest.fn(() =>
), -})); +import { Message } from '../../Message'; +import { Channel } from '../../Channel'; +import { Chat } from '../../Chat'; -const wrapperMock = document.createElement('div'); -jest.spyOn(wrapperMock, 'addEventListener'); +expect.extend(toHaveNoViolations); -const defaultProps = { - getMessageActions: () => ['flag', 'mute'], - handleDelete: () => {}, - handleFlag: () => {}, - handleMute: () => {}, - handlePin: () => {}, - message: generateMessage(), -}; +const alice = generateUser({ name: 'alice' }); +const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions-toggle-button'; +const MESSAGE_ACTIONS_HOST_TEST_ID = 'message-actions-host'; +const dialogOverlayTestId = 'str-chat__dialog-overlay'; +const threadActionTestId = 'thread-action'; +const reactionActionTestId = 'message-reaction-action'; +const reactionSelectorTestId = 'reaction-selector'; -const messageContextValue = { - getMessageActions: () => ['delete', 'edit', 'flag', 'mute', 'pin', 'react', 'reply'], - handleDelete: () => {}, - handleFlag: () => {}, - handleMute: () => {}, - handlePin: () => {}, +const defaultMessageContextValue = { + getMessageActions: () => [ + 'delete', + 'edit', + 'flag', + 'mute', + 'pin', + 'quote', + 'react', + 'reply', + ], + handleDelete: jest.fn(), + handleEdit: jest.fn(), + handleFlag: jest.fn(), + handleMarkUnread: jest.fn(), + handleMute: jest.fn(), + handleOpenThread: jest.fn(), + handlePin: jest.fn(), isMyMessage: () => false, message: generateMessage(), - setEditingState: () => {}, + setEditingState: jest.fn(), +}; + +const toggleOpenMessageActions = async (index = 0) => { + await act(async () => { + const buttons = screen.getAllByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID); + await fireEvent.click(buttons[index]); + }); }; -const chatClient = getTestClient(); +async function renderMessageActions({ + channelConfig = {}, + channelStateOpts = {}, + chatClient, + customChatContext = {}, + customMessageContext = {}, + messageActionsProps = {}, +} = {}) { + const client = chatClient || (await getTestClientWithUser(alice)); + const channel = generateChannel({ + getConfig: () => channelConfig, + state: { membership: {} }, + ...channelStateOpts, + }); -function renderMessageActions(customProps = {}) { return render( - - - - + + + + - - - + + + + + - + , ); } -const dialogOverlayTestId = 'str-chat__dialog-overlay'; -const messageActionsTestId = 'message-actions'; - -const toggleOpenMessageActions = async () => { - await act(async () => { - await fireEvent.click(screen.getByRole('button')); - }); -}; -describe(' component', () => { - afterEach(cleanup); +describe('', () => { beforeEach(jest.clearAllMocks); - it('should render correctly when not open', () => { - const { container } = renderMessageActions(); - expect(container).toMatchInlineSnapshot(` -
-
- -
-
- `); - }); + describe('Rendering and visibility', () => { + it('should not render when there are no actions available', async () => { + const { queryByTestId } = await renderMessageActions({ + messageActionsProps: { + disableBaseMessageActionSetFilter: true, + messageActionSet: [], + }, + }); + expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); + }); - it('should not return anything if message has no actions available', () => { - const { queryByTestId } = renderMessageActions({ - getMessageActions: () => [], + it('should not render when message is not set', async () => { + const { queryByTestId } = await renderMessageActions({ + customMessageContext: { + message: {}, + }, + }); + expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); + }); + + it.each([ + ['type', 'error'], + ['type', 'system'], + ['type', 'ephemeral'], + ['status', 'failed'], + ['status', 'sending'], + ])('should not render when message is of %s %s', async (key, value) => { + const message = generateMessage({ [key]: value, user: alice }); + const { queryByTestId } = await renderMessageActions({ + customMessageContext: { message }, + }); + expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not render when message is parent message in a thread', async () => { + const { queryByTestId } = await renderMessageActions({ + customMessageContext: { + initialMessage: true, + }, + }); + expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render correctly when not open', async () => { + const { container } = await renderMessageActions(); + expect(container.querySelector('.str-chat__message-options')).toBeInTheDocument(); + const button = screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-haspopup', 'true'); }); - // eslint-disable-next-line jest-dom/prefer-in-document - expect(queryByTestId(messageActionsTestId)).toBeNull(); - }); - it('should open message actions box on click', async () => { - renderMessageActions(); - expect(MessageActionsBoxMock).not.toHaveBeenCalled(); - await act(async () => { + it('should apply active class when dropdown is open', async () => { + const { container } = await renderMessageActions(); + const actionsHost = container.querySelector('.str-chat__message-options'); + expect(actionsHost).not.toHaveClass('str-chat__message-options--active'); + await toggleOpenMessageActions(); + + expect(actionsHost).toHaveClass('str-chat__message-options--active'); }); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: true }), - undefined, - ); - const dialogOverlay = screen.getByTestId(dialogOverlayTestId); - expect(dialogOverlay.children.length).toBeGreaterThan(0); }); - it('should close message actions box on icon click if already opened', async () => { - renderMessageActions(); - expect(MessageActionsBoxMock).not.toHaveBeenCalled(); - const dialogOverlay = screen.queryByTestId(dialogOverlayTestId); - expect(dialogOverlay).not.toBeInTheDocument(); - await toggleOpenMessageActions(); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: true }), - undefined, - ); - await toggleOpenMessageActions(); - await waitFor(() => { - expect(dialogOverlay).not.toBeInTheDocument(); + describe('Dropdown actions', () => { + it('should open dropdown when toggle button is clicked', async () => { + await renderMessageActions(); + expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument(); + + await toggleOpenMessageActions(); + + expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument(); + expect(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('should close dropdown when toggle button is clicked again', async () => { + await renderMessageActions(); + await toggleOpenMessageActions(); + expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument(); + + await toggleOpenMessageActions(); + + await waitFor(() => { + expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument(); + }); }); - }); - it('should close message actions box when user clicks overlay if it is already opened', async () => { - renderMessageActions(); - await toggleOpenMessageActions(); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: true }), - undefined, - ); - const dialogOverlay = screen.queryByTestId(dialogOverlayTestId); - await act(async () => { - await fireEvent.click(dialogOverlay); - }); - expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1); - expect(dialogOverlay).not.toBeInTheDocument(); + it('should close dropdown when overlay is clicked', async () => { + await renderMessageActions(); + await toggleOpenMessageActions(); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + + await act(async () => { + await fireEvent.click(dialogOverlay); + }); + + expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument(); + }); + + it('should close dropdown when Escape key is pressed', async () => { + await renderMessageActions(); + await toggleOpenMessageActions(); + expect(screen.getByTestId(dialogOverlayTestId)).toBeInTheDocument(); + + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + + expect(screen.queryByTestId(dialogOverlayTestId)).not.toBeInTheDocument(); + }); }); - it('should close message actions box when user presses Escape key', async () => { - renderMessageActions(); - await toggleOpenMessageActions(); - await act(async () => { - await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + describe('Dropdown action buttons', () => { + it('should render and call handleDelete when Delete button is clicked', async () => { + const handleDelete = jest.fn(); + const message = generateMessage({ user: alice }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'delete-own-message': true }, + }, + customMessageContext: { handleDelete, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(getByText('Delete')); + }); + + expect(handleDelete).toHaveBeenCalledTimes(1); + }); + + it('should render and call handleEdit when Edit Message button is clicked', async () => { + const handleEdit = jest.fn(); + const message = generateMessage({ user: alice }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'update-own-message': true }, + }, + customMessageContext: { handleEdit, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(getByText('Edit Message')); + }); + + expect(handleEdit).toHaveBeenCalledTimes(1); + }); + + it('should render and call handleFlag when Flag button is clicked', async () => { + const handleFlag = jest.fn(); + const otherUser = generateUser(); + const message = generateMessage({ user: otherUser }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'flag-message': true }, + }, + customMessageContext: { handleFlag, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(getByText('Flag')); + }); + + expect(handleFlag).toHaveBeenCalledTimes(1); + }); + + it('should render and call handleMute when Mute button is clicked', async () => { + const handleMute = jest.fn(); + const otherUser = generateUser(); + const message = generateMessage({ user: otherUser }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'mute-channel': true }, + }, + customMessageContext: { handleMute, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(getByText('Mute')); + }); + + expect(handleMute).toHaveBeenCalledTimes(1); + }); + + it('should show Unmute text when user is muted', async () => { + const otherUser = generateUser(); + const message = generateMessage({ user: otherUser }); + const handleMute = jest.fn(); + const chatClient = await getTestClientWithUser(alice); + const mutes = [{ target: otherUser, user: alice }]; + + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'mute-channel': true }, + }, + chatClient, + customChatContext: { mutes }, + customMessageContext: { handleMute, message }, + }); + await toggleOpenMessageActions(); + + expect(getByText('Unmute')).toBeInTheDocument(); + }); + + it('should render and call handlePin when Pin button is clicked', async () => { + const handlePin = jest.fn(); + const message = generateMessage({ pinned: false }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'pin-message': true }, + }, + customMessageContext: { handlePin, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(getByText('Pin')); + }); + + expect(handlePin).toHaveBeenCalledTimes(1); + }); + + it('should show Unpin text when message is pinned', async () => { + const message = generateMessage({ pinned: true }); + const { getByText } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'pin-message': true }, + }, + customMessageContext: { message }, + }); + await toggleOpenMessageActions(); + + expect(getByText('Unpin')).toBeInTheDocument(); + }); + + it('should set quoted message when Quote button is clicked', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels(); + const message = generateMessage({ user: client.user }); + const setQuotedMessageSpy = jest.spyOn(channel.messageComposer, 'setQuotedMessage'); + + await act(async () => { + await render( + + + + + + + + + + + + + + + , + ); + }); + + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(screen.getByText('Quote')); + }); + + expect(setQuotedMessageSpy).toHaveBeenCalledWith(message); }); - expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1); - const dialogOverlay = screen.queryByTestId(dialogOverlayTestId); - expect(dialogOverlay).not.toBeInTheDocument(); }); - it('should render the message actions box correctly', async () => { - renderMessageActions(); - await toggleOpenMessageActions(); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - getMessageActions: defaultProps.getMessageActions, - handleDelete: defaultProps.handleDelete, - handleEdit: expect.any(Function), - handleFlag: defaultProps.handleFlag, - handleMute: defaultProps.handleMute, - handlePin: defaultProps.handlePin, - isUserMuted: expect.any(Function), - mine: false, - open: true, - }), - undefined, - ); + describe('Quick actions', () => { + it('should display thread (reply) action when channel has replies enabled', async () => { + const { getByTestId } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reply': true }, + channelConfig: { replies: true }, + }, + }); + + expect(getByTestId(threadActionTestId)).toBeInTheDocument(); + }); + + it('should not display thread action when channel does not have replies enabled', async () => { + const { queryByTestId } = await renderMessageActions({ + channelStateOpts: { + channelConfig: { replies: false }, + }, + }); + + expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument(); + }); + + it('should not display thread action when message is in a thread', async () => { + const message = generateMessage({ parent_id: 'parent-123' }); + const { queryByTestId } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reply': true }, + channelConfig: { replies: true }, + }, + customMessageContext: { + message, + }, + }); + + expect(queryByTestId(threadActionTestId)).not.toBeInTheDocument(); + }); + + it('should call handleOpenThread when Reply button is clicked', async () => { + const handleOpenThread = jest.fn(); + const { getByTestId } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reply': true }, + channelConfig: { replies: true }, + }, + customMessageContext: { + handleOpenThread, + }, + }); + + await act(async () => { + await fireEvent.click(getByTestId(threadActionTestId)); + }); + + expect(handleOpenThread).toHaveBeenCalledTimes(1); + }); + + it('should display reaction action when channel has reactions enabled', async () => { + const { getByTestId } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + + expect(getByTestId(reactionActionTestId)).toBeInTheDocument(); + }); + + it('should not display reaction action when channel has reactions disabled', async () => { + const { queryByTestId } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': false }, + }, + }); + + expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument(); + }); }); - it('should not register click and keyup event listeners to close actions box until opened', async () => { - renderMessageActions(); - const addEventListener = jest.spyOn(document, 'addEventListener'); - expect(document.addEventListener).not.toHaveBeenCalled(); - await toggleOpenMessageActions(); - expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); - addEventListener.mockClear(); + describe('Reaction selector', () => { + it('should not render ReactionSelector until reaction button is clicked', async () => { + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + + expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument(); + + await act(async () => { + await fireEvent.click(screen.getByTestId(reactionActionTestId)); + }); + + expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument(); + }); + + it('should close ReactionSelector when dialog overlay is clicked', async () => { + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + + await act(async () => { + await fireEvent.click(screen.getByTestId(reactionActionTestId)); + }); + + expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument(); + + await act(async () => { + await fireEvent.click(screen.getByTestId(dialogOverlayTestId)); + }); + + expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument(); + }); + + it('should close ReactionSelector when Escape key is pressed', async () => { + await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + + await act(async () => { + await fireEvent.click(screen.getByTestId(reactionActionTestId)); + }); + + expect(screen.getByTestId(reactionSelectorTestId)).toBeInTheDocument(); + + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + + expect(screen.queryByTestId(reactionSelectorTestId)).not.toBeInTheDocument(); + }); + + it('should apply active class when ReactionSelector is open', async () => { + const { container } = await renderMessageActions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + + const actionsHost = container.querySelector('.str-chat__message-options'); + expect(actionsHost).not.toHaveClass('str-chat__message-options--active'); + + await act(async () => { + await fireEvent.click(screen.getByTestId(reactionActionTestId)); + }); + + expect(actionsHost).toHaveClass('str-chat__message-options--active'); + }); }); - it('should remove keyup event listener when unmounted if actions box not opened', async () => { - const { unmount } = renderMessageActions(); - const removeEventListener = jest.spyOn(document, 'removeEventListener'); - expect(document.removeEventListener).not.toHaveBeenCalled(); - await toggleOpenMessageActions(); - unmount(); - expect(document.removeEventListener).toHaveBeenCalledWith( - 'keyup', - expect.any(Function), - ); - removeEventListener.mockClear(); + describe('Mark as unread action', () => { + const ACTION_TEXT = 'Mark as unread'; + const me = generateUser(); + const otherUser = generateUser(); + const message = generateMessage({ user: otherUser }); + const lastReceivedId = message.id; + const read = [ + { + last_read: new Date(), + last_read_message_id: message.id, + unread_messages: 0, + user: me, + }, + ]; + const own_capabilities = [ + 'ban-channel-members', + 'connect-events', + 'delete-any-message', + 'delete-own-message', + 'flag-message', + 'mute-channel', + 'pin-message', + 'quote-message', + 'read-events', + 'send-message', + 'send-reaction', + 'send-reply', + 'update-any-message', + 'update-own-message', + ]; + + const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) => + await act(async () => { + await render( + + + + + + + , + ); + }); + + afterEach(jest.restoreAllMocks); + + it('should not be displayed without "read-events" capability', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [ + { + channel: { + own_capabilities: own_capabilities.filter((c) => c !== 'read-events'), + }, + messages: [message], + read, + }, + ], + customUser: me, + }); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { message }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); + }); + + it('should be displayed for own messages', async () => { + const myMessage = { ...message, user: me }; + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [ + { + channel: { own_capabilities }, + messages: [myMessage], + read, + }, + ], + customUser: me, + }); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { message: myMessage }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); + }); + + it('should not be displayed for thread messages', async () => { + const threadMessage = generateMessage({ parent_id: 'parent-123', user: otherUser }); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [ + { channel: { own_capabilities }, messages: [threadMessage], read }, + ], + customUser: me, + }); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { message: threadMessage, threadList: true }, + }); + await toggleOpenMessageActions(); + + expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); + }); + + it('should call channel.markUnread when Mark as unread button is clicked', async () => { + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { own_capabilities }, messages: [message], read }], + customUser: me, + }); + jest.spyOn(channel, 'markUnread'); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(screen.getByText(ACTION_TEXT)); + }); + + expect(channel.markUnread).toHaveBeenCalledWith( + expect.objectContaining({ message_id: message.id }), + ); + }); + + it('should call custom success notification on successful mark unread', async () => { + const getMarkMessageUnreadSuccessNotification = jest.fn(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { own_capabilities }, messages: [message], read }], + customUser: me, + }); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { getMarkMessageUnreadSuccessNotification, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(screen.getByText(ACTION_TEXT)); + }); + + expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith( + expect.objectContaining(message), + ); + }); + + it('should call custom error notification on failed mark unread', async () => { + const getMarkMessageUnreadErrorNotification = jest.fn(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [{ channel: { own_capabilities }, messages: [message], read }], + customUser: me, + }); + jest.spyOn(channel, 'markUnread').mockRejectedValueOnce(); + + await renderMarkUnreadUI({ + channelProps: { channel }, + chatProps: { client }, + messageProps: { getMarkMessageUnreadErrorNotification, message }, + }); + await toggleOpenMessageActions(); + + await act(async () => { + await fireEvent.click(screen.getByText(ACTION_TEXT)); + }); + + expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith( + expect.objectContaining(message), + ); + }); }); - it('should render with a custom wrapper class when one is set', () => { - const { container } = renderMessageActions({ - customWrapperClass: 'custom-wrapper-class', - }); - expect(container).toMatchInlineSnapshot(` -
-
- -
-
- `); + describe('Custom message action sets', () => { + it('should render custom action components', async () => { + const CustomAction = () => ; + const customMessageActionSet = [ + { + Component: CustomAction, + placement: 'dropdown', + type: 'custom', + }, + ]; + + await renderMessageActions({ + messageActionsProps: { + disableBaseMessageActionSetFilter: true, + messageActionSet: customMessageActionSet, + }, + }); + await toggleOpenMessageActions(); + + expect(screen.getByTestId('custom-action')).toBeInTheDocument(); + }); + + it('should support custom quick actions', async () => { + const CustomQuickAction = () => ( + + ); + const customMessageActionSet = [ + { + Component: CustomQuickAction, + placement: 'quick', + type: 'customQuick', + }, + ]; + + await renderMessageActions({ + messageActionsProps: { + disableBaseMessageActionSetFilter: true, + messageActionSet: customMessageActionSet, + }, + }); + + expect(screen.getByTestId('custom-quick-action')).toBeInTheDocument(); + }); + + it('should allow disabling base filter', async () => { + const message = generateMessage({ status: 'failed' }); + const { queryByTestId } = await renderMessageActions({ + customMessageContext: { message }, + messageActionsProps: { + disableBaseMessageActionSetFilter: true, + }, + }); + + // Should render even for failed messages when filter is disabled + expect(queryByTestId(MESSAGE_ACTIONS_HOST_TEST_ID)).toBeInTheDocument(); + }); }); - it('should render with an inline element wrapper when inline set', () => { - const { container } = renderMessageActions({ - inline: true, - }); - expect(container).toMatchInlineSnapshot(` -
- - - -
- `); + describe('Accessibility', () => { + it('should have no accessibility violations when closed', async () => { + const { container } = await renderMessageActions(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have no accessibility violations when dropdown is open', async () => { + const { container } = await renderMessageActions(); + await toggleOpenMessageActions(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should have proper aria attributes', async () => { + await renderMessageActions(); + const button = screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID); + + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-haspopup', 'true'); + expect(button).toHaveAttribute('aria-label', 'Open Message Actions Menu'); + + await toggleOpenMessageActions(); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('should have role=listbox for dropdown actions list', async () => { + await renderMessageActions(); + await toggleOpenMessageActions(); + + const actionsList = screen.getByRole('listbox', { name: 'Message Options' }); + expect(actionsList).toBeInTheDocument(); + }); }); }); diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js deleted file mode 100644 index 427cb297ff..0000000000 --- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js +++ /dev/null @@ -1,570 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import { toHaveNoViolations } from 'jest-axe'; -import { axe } from '../../../../axe-helper'; -import { MessageActionsBox } from '../MessageActionsBox'; - -import { ChannelActionProvider } from '../../../context/ChannelActionContext'; -import { MessageProvider } from '../../../context/MessageContext'; -import { TranslationProvider } from '../../../context/TranslationContext'; - -import { - dispatchNotificationMarkUnread, - generateMessage, - generateUser, - initClientWithChannels, -} from '../../../mock-builders'; -import { Message } from '../../Message'; -import { Channel } from '../../Channel'; -import { Chat } from '../../Chat'; -import { - ChannelStateProvider, - ChatProvider, - ComponentProvider, - DialogManagerProvider, -} from '../../../context'; - -expect.extend(toHaveNoViolations); - -const getMessageActionsMock = jest.fn(() => []); - -const defaultMessageContextValue = { - message: generateMessage(), - messageListRect: {}, -}; - -const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions-toggle-button'; -const toggleOpenMessageActions = async (i = 0) => { - await act(async () => { - await fireEvent.click(screen.getAllByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)[i]); - }); -}; - -async function renderComponent(boxProps = {}, messageContext = {}) { - const { - channels: [channel], - client, - } = await initClientWithChannels(); - return { - channel, - result: render( - - key }}> - - - - - - - - - - - - - , - ), - }; -} - -describe('MessageActionsBox', () => { - afterEach(jest.clearAllMocks); - - it('should not show any of the action buttons if no actions are returned by getMessageActions', async () => { - const { - result: { container, queryByText }, - } = await renderComponent({ message: generateMessage() }); - expect(queryByText('Flag')).not.toBeInTheDocument(); - expect(queryByText('Mute')).not.toBeInTheDocument(); - expect(queryByText('Unmute')).not.toBeInTheDocument(); - expect(queryByText('Edit Message')).not.toBeInTheDocument(); - expect(queryByText('Delete')).not.toBeInTheDocument(); - expect(queryByText('Pin')).not.toBeInTheDocument(); - expect(queryByText('Unpin')).not.toBeInTheDocument(); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleFlag prop if the flag button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['flag']); - const handleFlag = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ handleFlag, message: generateMessage() }); - await act(async () => { - await fireEvent.click(getByText('Flag')); - }); - expect(handleFlag).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleMute prop if the mute button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['mute']); - const handleMute = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ - handleMute, - isUserMuted: () => false, - message: generateMessage(), - }); - await act(async () => { - await fireEvent.click(getByText('Mute')); - }); - expect(handleMute).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleMute prop if the unmute button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['mute']); - const handleMute = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ - handleMute, - isUserMuted: () => true, - message: generateMessage(), - }); - await act(async () => { - await fireEvent.click(getByText('Unmute')); - }); - expect(handleMute).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleEdit prop if the edit button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['edit']); - const handleEdit = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ handleEdit, message: generateMessage() }); - await act(async () => { - await fireEvent.click(getByText('Edit Message')); - }); - expect(handleEdit).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleDelete prop if the delete button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['delete']); - const handleDelete = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ handleDelete, message: generateMessage() }); - await act(async () => { - await fireEvent.click(getByText('Delete')); - }); - expect(handleDelete).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handleDelete prop if the deleteForMe button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['deleteForMe']); - const handleDelete = jest.fn(); - const { - result: { container, getByText }, - } = await renderComponent({ handleDelete, message: generateMessage() }); - await act(async () => { - await fireEvent.click(getByText('Delete for me')); - }); - expect(handleDelete).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handlePin prop if the pin button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['pin']); - const handlePin = jest.fn(); - const message = generateMessage({ message: generateMessage(), pinned: false }); - const { - result: { container, getByText }, - } = await renderComponent({ handlePin, message }); - await act(async () => { - await fireEvent.click(getByText('Pin')); - }); - expect(handlePin).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call the handlePin prop if the unpin button is clicked', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['pin']); - const handlePin = jest.fn(); - const message = generateMessage({ message: generateMessage(), pinned: true }); - const { - result: { container, getByText }, - } = await renderComponent({ handlePin, message }); - await act(async () => { - await fireEvent.click(getByText('Unpin')); - }); - expect(handlePin).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - it('should call use MessageComposer to set quoted message', async () => { - getMessageActionsMock.mockImplementationOnce(() => ['quote']); - const { - channel, - result: { container, getByText }, - } = await renderComponent({ message: generateMessage() }); - const setQuotedMessageSpy = jest.spyOn(channel.messageComposer, 'setQuotedMessage'); - await act(async () => { - await fireEvent.click(getByText('Reply')); - }); - expect(setQuotedMessageSpy).toHaveBeenCalledTimes(1); - const results = await axe(container); - expect(results).toHaveNoViolations(); - }); - - describe('mark message unread', () => { - afterEach(jest.restoreAllMocks); - const ACTION_TEXT = 'Mark as unread'; - const me = generateUser(); - const otherUser = generateUser(); - const message = generateMessage({ user: otherUser }); - const lastReceivedId = message.id; - const read = [ - { - last_read: new Date(), - last_read_message_id: message.id, // optional - unread_messages: 0, - user: me, - }, - ]; - const own_capabilities = [ - 'ban-channel-members', - 'connect-events', - 'create-call', - 'delete-any-message', - 'delete-channel', - 'delete-own-message', - 'flag-message', - 'freeze-channel', - 'join-call', - 'join-channel', - 'leave-channel', - 'mute-channel', - 'pin-message', - 'quote-message', - 'read-events', - 'search-messages', - 'send-custom-events', - 'send-links', - 'send-message', - 'send-reaction', - 'send-reply', - 'send-typing-events', - 'set-channel-cooldown', - 'skip-slow-mode', - 'typing-events', - 'update-any-message', - 'update-channel', - 'update-channel-members', - 'update-own-message', - 'upload-file', - ]; - const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) => - await act(async () => { - await render( - - - - - - - , - ); - }); - - it('should not be displayed as an option in channels without "read-events" capability', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [ - { - channel: { - own_capabilities: own_capabilities.filter((c) => c !== 'read-events'), - }, - messages: [message], - read, - }, - ], - customUser: me, - }); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message }, - }); - await toggleOpenMessageActions(); - expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); - }); - - it('should be displayed as an option for own messages', async () => { - const myMessage = { ...message, user: me }; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [ - { - channel: { own_capabilities }, - messages: [myMessage], - read, - }, - ], - customUser: me, - }); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message: myMessage }, - }); - await toggleOpenMessageActions(); - expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); - }); - - it('should not be displayed as an option for thread messages', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities }, messages: [message], read }], - customUser: me, - }); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message, threadList: true }, - }); - await toggleOpenMessageActions(); - expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); - }); - - it('should be displayed as an option for message already marked unread', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities }, messages: [message], read }], - customUser: me, - }); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message }, - }); - - await act(() => { - dispatchNotificationMarkUnread({ - channel, - client, - payload: { - first_unread_message_id: message.id, - last_read: new Date(new Date(message.created_at).getTime() - 1000), - last_read_message_id: new Date().toISOString(), // any other message id always unique - unread_messages: 1, - user: client.user, - }, - }); - }); - - await toggleOpenMessageActions(); - expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); - }); - - it('should not be displayed as an option for message without id', async () => { - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const messageWithoutID = { ...message, id: undefined }; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [ - { - channel: { own_capabilities }, - messages: [messageWithoutID], - read, - }, - ], - customUser: me, - }); - jest.spyOn(channel, 'markUnread'); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message: messageWithoutID }, - }); - await toggleOpenMessageActions(); - expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); - }); - - it('should be displayed as an option for messages not marked and marked unread', async () => { - const otherMsg = generateMessage({ - created_at: new Date(new Date(message.created_at).getTime() + 2000), - }); - const read = [ - { - first_unread_message_id: otherMsg.id, - last_read: new Date(new Date(otherMsg.created_at).getTime() - 1000), - // last_read_message_id: message.id, // optional - unread_messages: 2, - user: me, - }, - ]; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [ - { - channel: { own_capabilities }, - messages: [message, otherMsg], - read, - }, - ], - customUser: me, - }); - - await act(async () => { - await render( - - - - - - - - , - ); - }); - await toggleOpenMessageActions(0); - let boxes = screen.getAllByTestId('message-actions-box'); - - expect(boxes).toHaveLength(1); - expect(boxes[0]).toHaveTextContent(ACTION_TEXT); - - await toggleOpenMessageActions(1); - boxes = screen.getAllByTestId('message-actions-box'); - - expect(boxes).toHaveLength(1); - expect(boxes[0]).toHaveTextContent(ACTION_TEXT); - }); - - it('should be displayed and execute API request', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities }, messages: [message], read }], - customUser: me, - }); - jest.spyOn(channel, 'markUnread'); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { message }, - }); - await toggleOpenMessageActions(); - - await act(async () => { - await fireEvent.click(screen.getByText(ACTION_TEXT)); - }); - expect(channel.markUnread).toHaveBeenCalledWith( - expect.objectContaining({ message_id: message.id }), - ); - }); - - it('should allow mark message unread and notify with custom success notification', async () => { - const getMarkMessageUnreadSuccessNotification = jest.fn(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities }, messages: [message], read }], - customUser: me, - }); - jest.spyOn(channel, 'markUnread'); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { getMarkMessageUnreadSuccessNotification, message }, - }); - await toggleOpenMessageActions(); - await act(async () => { - await fireEvent.click(screen.getByText(ACTION_TEXT)); - }); - expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith( - expect.objectContaining(message), - ); - }); - - it('should allow mark message unread and notify with custom error notification', async () => { - const getMarkMessageUnreadErrorNotification = jest.fn(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [{ channel: { own_capabilities }, messages: [message], read }], - customUser: me, - }); - jest.spyOn(channel, 'markUnread').mockRejectedValueOnce(); - - await renderMarkUnreadUI({ - channelProps: { channel }, - chatProps: { client }, - messageProps: { getMarkMessageUnreadErrorNotification, message }, - }); - await toggleOpenMessageActions(); - await act(async () => { - await fireEvent.click(screen.getByText(ACTION_TEXT)); - }); - expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith( - expect.objectContaining(message), - ); - }); - }); -}); diff --git a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts index eb1387852b..96d12dc311 100644 --- a/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts +++ b/src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts @@ -1,7 +1,8 @@ import { useMemo } from 'react'; -import { ACTIONS_NOT_WORKING_IN_THREAD, useUserRole } from '../../../components'; import { useChannelStateContext, useMessageContext } from '../../../context'; +import { useUserRole } from '../../Message/hooks'; +import { ACTIONS_NOT_WORKING_IN_THREAD } from '../../Message/utils'; import type { MessageActionSetItem } from '../MessageActions'; diff --git a/src/components/MessageInput/__tests__/MessageInput.test.js b/src/components/MessageInput/__tests__/MessageInput.test.js index 0ce72535ec..21c4123012 100644 --- a/src/components/MessageInput/__tests__/MessageInput.test.js +++ b/src/components/MessageInput/__tests__/MessageInput.test.js @@ -8,9 +8,9 @@ import { nanoid } from 'nanoid'; import { MessageInput } from '../MessageInput'; import { Channel } from '../../Channel/Channel'; -import { MessageActionsBox } from '../../MessageActions'; +import { MessageActions } from '../../MessageActions'; -import { MessageProvider } from '../../../context/MessageContext'; +import { DialogManagerProvider, MessageProvider } from '../../../context'; import { ChatProvider } from '../../../context/ChatContext'; import { dispatchMessageDeletedEvent, @@ -92,6 +92,7 @@ const defaultMessageContextValue = { handleDelete: () => {}, handleFlag: () => {}, handleMute: () => {}, + handleOpenThread: () => {}, handlePin: () => {}, isMyMessage: () => true, message: mainListMessage, @@ -110,7 +111,14 @@ function dropFile(file, formElement) { const initQuotedMessagePreview = async (message) => { await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument()); - const quoteButton = await screen.findByText(/^reply$/i); + // Open the message actions dropdown + const actionsButton = await screen.findByTestId('message-actions-toggle-button'); + await act(() => { + fireEvent.click(actionsButton); + }); + + // Click the Quote button in the dropdown + const quoteButton = await screen.findByText(/^quote$/i); await waitFor(() => expect(quoteButton).toBeInTheDocument()); act(() => { @@ -137,7 +145,7 @@ const renderComponent = async ({ customChannel, customClient, customUser, - messageActionsBoxProps = {}, + messageActionsProps = {}, messageContextOverrides = {}, messageInputProps = {}, } = {}) => { @@ -158,17 +166,19 @@ const renderComponent = async ({ - - - - - - + + + + + + + + , ); }); diff --git a/src/components/MessageInput/__tests__/ThreadMessageInput.test.js b/src/components/MessageInput/__tests__/ThreadMessageInput.test.js index 663d892e5c..294336ea56 100644 --- a/src/components/MessageInput/__tests__/ThreadMessageInput.test.js +++ b/src/components/MessageInput/__tests__/ThreadMessageInput.test.js @@ -7,9 +7,8 @@ import { initClientWithChannels, } from '../../../mock-builders'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { ChatProvider, MessageProvider, useChannelActionContext } from '../../../context'; +import { ChatProvider, useChannelActionContext } from '../../../context'; import { Channel } from '../../Channel'; -import { MessageActionsBox } from '../../MessageActions'; import React, { useEffect, useRef } from 'react'; import { SearchController } from 'stream-chat'; import { MessageInput } from '../MessageInput'; @@ -53,17 +52,6 @@ const defaultChatContext = { searchController: new SearchController(), }; -const defaultMessageContextValue = { - getMessageActions: () => ['delete', 'edit', 'quote'], - handleDelete: () => {}, - handleFlag: () => {}, - handleMute: () => {}, - handlePin: () => {}, - isMyMessage: () => true, - message: mainListMessage, - setEditingState: () => {}, -}; - const setup = async ({ channelData } = {}) => { const { channels: [customChannel], @@ -103,8 +91,6 @@ const renderComponent = async ({ customChannel, customClient, customUser, - messageActionsBoxProps = {}, - messageContextOverrides = {}, messageInputProps = {}, thread, } = {}) => { @@ -127,14 +113,6 @@ const renderComponent = async ({ > - - -