diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx index 35cce3268..d18a8d436 100644 --- a/docusaurus/docs/React/components/contexts/message-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-context.mdx @@ -304,14 +304,6 @@ Function that runs on hover of an @mention in a message. | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### onReactionListClick - -Function that runs on click of the reactions list component. - -| Type | -| ----------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | - ### onUserClick Function that runs on click of a user avatar. @@ -336,14 +328,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f | ------ | ------------------------------------------------------------------------- | | object | | -### reactionSelectorRef - -Ref to be placed on the reaction selector component. - -| Type | -| --------------------------------------- | -| React.MutableRefObject | - ### readBy An array of users that have read the current message. @@ -368,14 +352,6 @@ Function to toggle the editing state on a message. | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### showDetailedReactions - -When true, show the reactions list component. - -| Type | -| ------- | -| boolean | - ### reactionDetailsSort Sort options to provide to a reactions query. Affects the order of reacted users in the default reactions modal. diff --git a/docusaurus/docs/React/components/message-components/message-ui.mdx b/docusaurus/docs/React/components/message-components/message-ui.mdx index e8ad602d3..7a6bdd4ea 100644 --- a/docusaurus/docs/React/components/message-components/message-ui.mdx +++ b/docusaurus/docs/React/components/message-components/message-ui.mdx @@ -397,14 +397,6 @@ Function that runs on hover of an @mention in a message (overrides the function | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onMentionsHoverMessage']](../contexts/channel-action-context.mdx#onmentionshovermessage) | -### onReactionListClick - -Function that runs on click of the reactions list component (overrides the function stored in `MessageContext`). - -| Type | Default | -| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/channel-action-context.mdx#onreactionlistclick) | - ### onUserClick Function that runs on click of a user avatar (overrides the function stored in `MessageContext`). @@ -429,14 +421,6 @@ The user roles allowed to pin messages in various channel types (deprecated in f | ------ | -------------------------------------------------------------------------------------------------------------------- | | object | [defaultPinPermissions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/utils.tsx) | -### reactionSelectorRef - -Ref to be placed on the reaction selector component (overrides the ref stored in `MessageContext`). - -| Type | -| --------------------------------------- | -| React.MutableRefObject | - ### readBy An array of users that have read the current message (overrides the value stored in `MessageContext`). @@ -461,14 +445,6 @@ Function to toggle the editing state on a message (overrides the function stored | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### showDetailedReactions - -When true, show the reactions list component (overrides the value stored in `MessageContext`). - -| Type | -| ------- | -| boolean | - ### threadList If true, indicates that the current `MessageList` component is part of a `Thread` (overrides the value stored in `MessageContext`). diff --git a/docusaurus/docs/React/components/message-components/reactions.mdx b/docusaurus/docs/React/components/message-components/reactions.mdx index 1f3a79613..68cade800 100644 --- a/docusaurus/docs/React/components/message-components/reactions.mdx +++ b/docusaurus/docs/React/components/message-components/reactions.mdx @@ -151,14 +151,6 @@ const MyCustomReactionsList = (props) => { }; ``` -### onClick - -Custom on click handler for an individual reaction in the list (overrides the function coming from `MessageContext`). - -| Type | Default | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| (event: React.BaseSyntheticEvent) => Promise \| void | [MessageContextValue['onReactionListClick']](../contexts/message-context.mdx#onreactionlistclick) | - ### own_reactions An array of the own reaction objects to distinguish own reactions visually (overrides `message.own_reactions` from `MessageContext`). diff --git a/docusaurus/docs/React/components/message-components/ui-components.mdx b/docusaurus/docs/React/components/message-components/ui-components.mdx index dd88e3c80..ff1a2e850 100644 --- a/docusaurus/docs/React/components/message-components/ui-components.mdx +++ b/docusaurus/docs/React/components/message-components/ui-components.mdx @@ -126,14 +126,6 @@ The `StreamChat` message object, which provides necessary data to the underlying | ------ | | object | -### messageWrapperRef - -React mutable ref placed on the message root `div`. It is forwarded by `MessageOptions` down to `MessageActions` ([see the example](../../guides/theming/message-ui.mdx)). - -| Type | -| -------------------------------- | -| React.RefObject | - ### mine Function that returns whether the message was sent by the connected user. @@ -178,14 +170,6 @@ Function that opens a [`Thread`](../core-components/thread.mdx) on a message (ov | ----------------------------------------------------------- | | (event: React.BaseSyntheticEvent) => Promise \| void | -### messageWrapperRef - -React mutable ref that can be placed on the message root `div`. `MessageOptions` component forwards this prop to [`MessageActions`](#messageactions-props) component ([see the example](../../guides/theming/message-ui.mdx)). - -| Type | -| -------------------------------- | -| React.RefObject | - ### ReactionIcon Custom component rendering the icon used in a message options button invoking reactions selector for a given message. diff --git a/docusaurus/docs/React/guides/dialog-management.mdx b/docusaurus/docs/React/guides/dialog-management.mdx new file mode 100644 index 000000000..f2c500115 --- /dev/null +++ b/docusaurus/docs/React/guides/dialog-management.mdx @@ -0,0 +1,107 @@ +--- +id: dialog-management +title: Dialog Management +--- + +This article presents the API the integrators can use to toggle display dialogs in their UIs. The default components that are displayed as dialogs are: + +- `ReactionSelector` - allows users to post reactions / emojis to a message +- `MessageActionsBox` - allows user to select from a list of permitted message actions + +The dialog management following this guide is enabled within `MessageList` and `VirtualizedMessageList`. + +## Setup dialog display + +There are two actors in the play. The first one is the component that requests the dialog to be closed or open and the other is the component that renders the dialog. We will start with demonstrating how to properly render a component in a dialog. + +### Rendering a dialog + +Component we want to be rendered as a floating dialog should be wrapped inside `DialogAnchor`: + +```tsx +import React, { ElementRef, useRef } from 'react'; +import { DialogAnchor } from 'stream-chat-react'; + +import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog'; +import { generateUniqueId } from './generateUniqueId'; + +const Container = () => { + // DialogAnchor needs a reference to the element that will toggle the open state. Based on this reference the dialog positioning is calculated + const buttonRef = useRef>(null); + // providing the dialog is necessary for the dialog to be retrieved from anywhere in the DialogManagerProviderContext + const dialogId = generateUniqueId(); + + return ( + <> + + + + + ); +}; +``` + +### Controlling a dialog's display + +The dialog display is controlled via Dialog API. You can access the API via `useDialog()` hook. + +```tsx +import React, { ElementRef, useRef } from 'react'; +import { DialogAnchor, useDialog, useDialogIsOpen } from 'stream-chat-react'; + +import { ComponentToDisplayOnDialog } from './ComponentToDisplayOnDialog'; +import { generateUniqueId } from './generateUniqueId'; + +const Container = () => { + const buttonRef = useRef>(null); + const dialogId = generateUniqueId(); + // access the dialog controller which provides the dialog API + const dialog = useDialog({ id: dialogId }); + // subscribe to dialog open state changes + const dialogIsOpen = useDialogIsOpen(dialogId); + + return ( + <> + + + + + + ); +}; +``` + +### Dialog API + +Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. The hook returns an object with the following API: + +- `dialog.open()` - opens the dialog +- `dialog.close()` - closes the dialog +- `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument `closeAll`. If enabled closes any other dialog that would be open. +- `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes) + +Every `Dialog` object carries its own `id` and `isOpen` flag. + +### Dialog utility hooks + +There are the following utility hooks that can be used to subscribe to state changes or access a given dialog: + +- `useDialogIsOpen(id: string)` - allows to observe the open state of a particular `Dialog` instance +- `useDialog({ id }: GetOrCreateDialogParams)` - retrieves a dialog object that exposes API to manage it +- `useOpenedDialogCount()` - allows to observe changes in the open dialog count + +### Custom dialog management context + +Those who would like to render dialogs outside the `MessageList` and `VirtualizedMessageList`, will need to create a dialog management context using `DialogManagerProvider`. + +```tsx +import { DialogManagerProvider } from 'stream-chat-react'; + +const Container = () => { + return ; +}; +``` + +Now the children of `DialogAnchor` will be anchored to the parent `DialogManagerProvider`. diff --git a/docusaurus/docs/React/guides/theming/message-ui.mdx b/docusaurus/docs/React/guides/theming/message-ui.mdx index d846e42ac..816aa6e59 100644 --- a/docusaurus/docs/React/guides/theming/message-ui.mdx +++ b/docusaurus/docs/React/guides/theming/message-ui.mdx @@ -387,7 +387,7 @@ const CustomMessageUi = () => { Message grouping is being managed automatically by the SDK and each parent element (which holds our message UI) receives an appropriate class name based on which we can adjust our rules to display metadata elements only when it's appropriate to make our UI look less busy. -{/_ TODO: link to grouping logic (maybe how to adjust it if needed) _/} +[//]: # 'TODO: link to grouping logic (maybe how to adjust it if needed)' ```css .custom-message-ui__metadata { diff --git a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx index 458a7f8b2..808b00892 100644 --- a/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx +++ b/docusaurus/docs/React/release-guides/upgrade-to-v12.mdx @@ -117,6 +117,38 @@ import { encodeToMp3 } from 'stream-chat-react/mp3-encoder'; ::: +## Unified dialog management + +Dialogs will be managed centrally. At the moment, this applies to display of `ReactionSelector` and `MessageActionsBox`. They will be displayed on a transparent overlay that prevents users from opening other dialogs in the message list. Once an option from a dialog is selected or the overlay is clicked, the dialog will disappear. This adjust brings new API and removes some properties from `MessageContextValue`. + +### Removed properties from MessageContextValue + +- `isReactionEnabled` - served to signal the permission to send reactions by the current user in a given channel. With the current permissions implementation, the permission can be determined by doing the following: + +``` +import { useMessageContext } from 'stream-chat-react'; + +const { getMessageActions } = useMessageContext(); +const messageActions = getMessageActions(); +const canReact = messageActions.includes(MESSAGE_ACTIONS.react); +``` + +- `onReactionListClick` - handler function that toggled the open state of `ReactionSelector` represented by another removed value - `showDetailedReactions` +- `showDetailedReactions` - flag used to decide, whether the reaction selector should be shown or not +- `reactionSelectorRef` - ref to the root of the reaction selector component (served to control the display of the component) + +Also prop `messageWrapperRef` was removed as part of the change from `MessageOptions` and `MessageActions` props. + +On the other hand, the `Message` prop (configuration parameter) `closeReactionSelectorOnClick` is now available in the `MessageContextValue`. + +:::important +If you used any of these values in your customizations, please make sure to adjust your implementation according to the newly recommended use of Dialog API in [Dialog management guide](../../guides/dialog-management). +::: + +### New dialog management API + +To learn about the new API, please, take a look at our [Dialog management guide](../../guides/dialog-management). + ## EmojiPickerIcon extraction to emojis plugin The default `EmojiPickerIcon` has been moved to emojis plugin from which we already import `EmojiPicker` component. diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index 9b85e6808..75e7f2f47 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -149,7 +149,8 @@ "guides/video-integration/video-integration-stream", "guides/sdk-state-management", "guides/date-time-formatting", - "guides/custom-threads-view" + "guides/custom-threads-view", + "guides/dialog-management" ] }, { diff --git a/package.json b/package.json index a9b3d6f05..a2152cbc9 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "@semantic-release/changelog": "^6.0.2", "@semantic-release/git": "^10.0.1", "@stream-io/rollup-plugin-node-builtins": "^2.1.5", - "@stream-io/stream-chat-css": "^5.0.0-rc.5", + "@stream-io/stream-chat-css": "5.0.0-rc.6", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^13.1.1", "@testing-library/react-hooks": "^8.0.0", diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index da8c9f569..6fa14b6cf 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -1,9 +1,10 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { ThreadProvider, useStateStore } from '../Threads'; +import { ThreadProvider } from '../Threads'; import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext } from '../../context'; +import { useStateStore } from '../../store'; import type { PropsWithChildren } from 'react'; import type { Thread, ThreadManagerState } from 'stream-chat'; diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx new file mode 100644 index 000000000..5e411ba0f --- /dev/null +++ b/src/components/Dialog/DialogAnchor.tsx @@ -0,0 +1,116 @@ +import clsx from 'clsx'; +import { Placement } from '@popperjs/core'; +import React, { ComponentProps, PropsWithChildren, useEffect, useState } from 'react'; +import { FocusScope } from '@react-aria/focus'; +import { usePopper } from 'react-popper'; +import { DialogPortalEntry } from './DialogPortal'; +import { useDialog, useDialogIsOpen } from './hooks'; + +export interface DialogAnchorOptions { + open: boolean; + placement: Placement; + referenceElement: HTMLElement | null; +} + +export function useDialogAnchor({ + open, + placement, + referenceElement, +}: DialogAnchorOptions) { + const [popperElement, setPopperElement] = useState(null); + const { attributes, styles, update } = usePopper(referenceElement, popperElement, { + modifiers: [ + { + name: 'eventListeners', + options: { + // It's not safe to update popper position on resize and scroll, since popper's + // reference element might not be visible at the time. + resize: false, + scroll: false, + }, + }, + ], + placement, + }); + + useEffect(() => { + if (open && popperElement) { + // Since the popper's reference element might not be (and usually is not) visible + // all the time, it's safer to force popper update before showing it. + // update is non-null only if popperElement is non-null + update?.(); + } + }, [open, popperElement, update]); + + if (popperElement && !open) { + setPopperElement(null); + } + + return { + attributes, + setPopperElement, + styles, + }; +} + +type DialogAnchorProps = PropsWithChildren> & { + id: string; + focus?: boolean; + trapFocus?: boolean; +} & ComponentProps<'div'>; + +export const DialogAnchor = ({ + children, + className, + focus = true, + id, + placement = 'auto', + referenceElement = null, + trapFocus, + ...restDivProps +}: DialogAnchorProps) => { + const dialog = useDialog({ id }); + const open = useDialogIsOpen(id); + const { attributes, setPopperElement, styles } = useDialogAnchor({ + open, + placement, + referenceElement, + }); + + useEffect(() => { + if (!open) return; + const hideOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + dialog?.close(); + }; + + document.addEventListener('keyup', hideOnEscape); + + return () => { + document.removeEventListener('keyup', hideOnEscape); + }; + }, [dialog, open]); + + // prevent rendering the dialog contents if the dialog should not be open / shown + if (!open) { + return null; + } + + return ( + + +
+ {children} +
+
+
+ ); +}; diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts new file mode 100644 index 000000000..503adbcf2 --- /dev/null +++ b/src/components/Dialog/DialogManager.ts @@ -0,0 +1,129 @@ +import { StateStore } from 'stream-chat'; + +export type GetOrCreateDialogParams = { + id: DialogId; +}; + +type DialogId = string; + +export type Dialog = { + close: () => void; + id: DialogId; + isOpen: boolean | undefined; + open: (zIndex?: number) => void; + remove: () => void; + toggle: (closeAll?: boolean) => void; +}; + +export type DialogManagerOptions = { + id?: string; +}; + +type Dialogs = Record; + +export type DialogManagerState = { + dialogsById: Dialogs; +}; + +/** + * Keeps a map of Dialog objects. + * Dialog can be controlled via `Dialog` object retrieved using `useDialog()` hook. + * The hook returns an object with the following API: + * + * - `dialog.open()` - opens the dialog + * - `dialog.close()` - closes the dialog + * - `dialog.toggle()` - toggles the dialog open state. Accepts boolean argument closeAll. If enabled closes any other dialog that would be open. + * - `dialog.remove()` - removes the dialog object reference from the state (primarily for cleanup purposes) + */ +export class DialogManager { + id: string; + state = new StateStore({ + dialogsById: {}, + }); + + constructor({ id }: DialogManagerOptions = {}) { + this.id = id ?? new Date().getTime().toString(); + } + + get openDialogCount() { + return Object.values(this.state.getLatestValue().dialogsById).reduce((count, dialog) => { + if (dialog.isOpen) return count + 1; + return count; + }, 0); + } + + getOrCreate({ id }: GetOrCreateDialogParams) { + let dialog = this.state.getLatestValue().dialogsById[id]; + if (!dialog) { + dialog = { + close: () => { + this.close(id); + }, + id, + isOpen: false, + open: () => { + this.open({ id }); + }, + remove: () => { + this.remove(id); + }, + toggle: (closeAll = false) => { + this.toggle({ id }, closeAll); + }, + }; + this.state.next((current) => ({ + ...current, + ...{ dialogsById: { ...current.dialogsById, [id]: dialog } }, + })); + } + return dialog; + } + + open(params: GetOrCreateDialogParams, closeRest?: boolean) { + const dialog = this.getOrCreate(params); + if (dialog.isOpen) return; + if (closeRest) { + this.closeAll(); + } + this.state.next((current) => ({ + ...current, + dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } }, + })); + } + + close(id: DialogId) { + const dialog = this.state.getLatestValue().dialogsById[id]; + if (!dialog?.isOpen) return; + this.state.next((current) => ({ + ...current, + dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } }, + })); + } + + closeAll() { + Object.values(this.state.getLatestValue().dialogsById).forEach((dialog) => dialog.close()); + } + + toggle(params: GetOrCreateDialogParams, closeAll = false) { + if (this.state.getLatestValue().dialogsById[params.id]?.isOpen) { + this.close(params.id); + } else { + this.open(params, closeAll); + } + } + + remove(id: DialogId) { + const state = this.state.getLatestValue(); + const dialog = state.dialogsById[id]; + if (!dialog) return; + + this.state.next((current) => { + const newDialogs = { ...current.dialogsById }; + delete newDialogs[id]; + return { + ...current, + dialogsById: newDialogs, + }; + }); + } +} diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx new file mode 100644 index 000000000..e9bb63de7 --- /dev/null +++ b/src/components/Dialog/DialogPortal.tsx @@ -0,0 +1,47 @@ +import React, { PropsWithChildren, useLayoutEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useDialogIsOpen, useOpenedDialogCount } from './hooks'; +import { useDialogManager } from '../../context'; + +export const DialogPortalDestination = () => { + const { dialogManager } = useDialogManager(); + const openedDialogCount = useOpenedDialogCount(); + + return ( +
dialogManager.closeAll()} + style={ + { + '--str-chat__dialog-overlay-height': openedDialogCount > 0 ? '100%' : '0', + } as React.CSSProperties + } + >
+ ); +}; + +type DialogPortalEntryProps = { + dialogId: string; +}; + +export const DialogPortalEntry = ({ + children, + dialogId, +}: PropsWithChildren) => { + const { dialogManager } = useDialogManager(); + const dialogIsOpen = useDialogIsOpen(dialogId); + const [portalDestination, setPortalDestination] = useState(null); + useLayoutEffect(() => { + const destination = document.querySelector( + `div[data-str-chat__portal-id="${dialogManager.id}"]`, + ); + if (!destination) return; + setPortalDestination(destination); + }, [dialogManager, dialogIsOpen]); + + if (!portalDestination) return null; + + return createPortal(children, portalDestination); +}; diff --git a/src/components/Dialog/__tests__/DialogsManager.test.js b/src/components/Dialog/__tests__/DialogsManager.test.js new file mode 100644 index 000000000..f27f4d846 --- /dev/null +++ b/src/components/Dialog/__tests__/DialogsManager.test.js @@ -0,0 +1,145 @@ +import { DialogManager } from '../DialogManager'; + +const dialogId = 'dialogId'; + +describe('DialogManager', () => { + it('initiates with provided options', () => { + const id = 'XX'; + const dialogManager = new DialogManager({ id }); + expect(dialogManager.id).toBe(id); + }); + it('initiates with default options', () => { + const mockedId = '12345'; + const spy = jest.spyOn(Date.prototype, 'getTime').mockReturnValueOnce(mockedId); + const dialogManager = new DialogManager(); + expect(dialogManager.id).toBe(mockedId); + spy.mockRestore(); + }); + it('creates a new closed dialog', () => { + const dialogManager = new DialogManager(); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0); + expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({ + close: expect.any(Function), + id: 'dialogId', + isOpen: false, + open: expect.any(Function), + remove: expect.any(Function), + toggle: expect.any(Function), + }); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); + expect(dialogManager.openDialogCount).toBe(0); + }); + + it('retrieves an existing dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.state.next((current) => ({ + ...current, + dialogsById: { ...current.dialogsById, [dialogId]: { id: dialogId, isOpen: true } }, + })); + expect(dialogManager.getOrCreate({ id: dialogId })).toMatchObject({ + id: 'dialogId', + isOpen: true, + }); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); + }); + + it('creates a dialog if it does not exist on open', () => { + const dialogManager = new DialogManager(); + dialogManager.open({ id: dialogId }); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId]).toMatchObject({ + close: expect.any(Function), + id: 'dialogId', + isOpen: true, + open: expect.any(Function), + remove: expect.any(Function), + toggle: expect.any(Function), + }); + expect(dialogManager.openDialogCount).toBe(1); + }); + + it('opens existing dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); + expect(dialogManager.openDialogCount).toBe(1); + }); + + it('does not open already open dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.open({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(1); + }); + + it('closes all other dialogsById before opening the target', () => { + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + expect(dialogManager.openDialogCount).toBe(2); + dialogManager.open({ id: dialogId }, true); + const dialogs = dialogManager.state.getLatestValue().dialogsById; + expect(dialogs.xxx.isOpen).toBeFalsy(); + expect(dialogs.yyy.isOpen).toBeFalsy(); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeTruthy(); + expect(dialogManager.openDialogCount).toBe(1); + }); + + it('closes opened dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.open({ id: dialogId }); + dialogManager.close(dialogId); + expect(dialogManager.state.getLatestValue().dialogsById[dialogId].isOpen).toBeFalsy(); + expect(dialogManager.openDialogCount).toBe(0); + }); + + it('does not close already closed dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: dialogId }); + dialogManager.close(dialogId); + dialogManager.close(dialogId); + expect(dialogManager.openDialogCount).toBe(1); + }); + + it('toggles the open state of a dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + dialogManager.toggle({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(3); + dialogManager.toggle({ id: dialogId }); + expect(dialogManager.openDialogCount).toBe(2); + }); + + it('keeps single opened dialog when the toggling open dialog state', () => { + const dialogManager = new DialogManager(); + + dialogManager.open({ id: 'xxx' }); + dialogManager.open({ id: 'yyy' }); + dialogManager.toggle({ id: dialogId }, true); + expect(dialogManager.openDialogCount).toBe(1); + + dialogManager.toggle({ id: dialogId }, true); + expect(dialogManager.openDialogCount).toBe(0); + }); + + it('removes a dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.remove(dialogId); + expect(dialogManager.openDialogCount).toBe(0); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0); + }); + + it('handles attempt to remove non-existent dialog', () => { + const dialogManager = new DialogManager(); + dialogManager.getOrCreate({ id: dialogId }); + dialogManager.open({ id: dialogId }); + dialogManager.remove('xxx'); + expect(dialogManager.openDialogCount).toBe(1); + expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(1); + }); +}); diff --git a/src/components/Dialog/hooks/index.ts b/src/components/Dialog/hooks/index.ts new file mode 100644 index 000000000..9d08c250c --- /dev/null +++ b/src/components/Dialog/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDialog'; diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts new file mode 100644 index 000000000..d0387ab9c --- /dev/null +++ b/src/components/Dialog/hooks/useDialog.ts @@ -0,0 +1,40 @@ +import { useCallback, useEffect } from 'react'; +import { useDialogManager } from '../../../context'; +import { useStateStore } from '../../../store'; + +import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager'; + +export const useDialog = ({ id }: GetOrCreateDialogParams) => { + const { dialogManager } = useDialogManager(); + + useEffect( + () => () => { + dialogManager.remove(id); + }, + [dialogManager, id], + ); + + return dialogManager.getOrCreate({ id }); +}; + +export const useDialogIsOpen = (id: string) => { + const { dialogManager } = useDialogManager(); + const dialogIsOpenSelector = useCallback( + ({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen] as const, + [id], + ); + return useStateStore(dialogManager.state, dialogIsOpenSelector)[0]; +}; + +const openedDialogCountSelector = (nextValue: DialogManagerState) => + [ + Object.values(nextValue.dialogsById).reduce((count, dialog) => { + if (dialog.isOpen) return count + 1; + return count; + }, 0), + ] as const; + +export const useOpenedDialogCount = () => { + const { dialogManager } = useDialogManager(); + return useStateStore(dialogManager.state, openedDialogCountSelector)[0]; +}; diff --git a/src/components/Dialog/index.ts b/src/components/Dialog/index.ts new file mode 100644 index 000000000..a2462dbcd --- /dev/null +++ b/src/components/Dialog/index.ts @@ -0,0 +1,4 @@ +export * from './DialogAnchor'; +export * from './DialogManager'; +export * from './DialogPortal'; +export * from './hooks'; diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index b353e1d7b..1ed4a60ce 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useActionHandler, @@ -10,7 +10,6 @@ import { useMuteHandler, useOpenThreadHandler, usePinHandler, - useReactionClick, useReactionHandler, useReactionsFetcher, useRetryHandler, @@ -46,14 +45,10 @@ type MessageContextPropsToPick = | 'handleReaction' | 'handleFetchReactions' | 'handleRetry' - | 'isReactionEnabled' | 'mutes' | 'onMentionsClickMessage' | 'onMentionsHoverMessage' - | 'onReactionListClick' - | 'reactionSelectorRef' | 'reactionDetailsSort' - | 'showDetailedReactions' | 'sortReactions' | 'sortReactionDetails'; @@ -220,8 +215,6 @@ export const Message = < const { addNotification } = useChannelActionContext('Message'); const { highlightedMessageId, mutes } = useChannelStateContext('Message'); - const reactionSelectorRef = useRef(null); - const handleAction = useActionHandler(message); const handleOpenThread = useOpenThreadHandler(message, propOpenThread); const handleReaction = useReactionHandler(message); @@ -266,13 +259,6 @@ export const Message = < notify: addNotification, }); - const { isReactionEnabled, onReactionListClick, showDetailedReactions } = useReactionClick( - message, - reactionSelectorRef, - undefined, - closeReactionSelectorOnClick, - ); - const highlighted = highlightedMessageId === message.id; return ( @@ -280,6 +266,7 @@ export const Message = < additionalMessageInputProps={props.additionalMessageInputProps} autoscrollToBottom={props.autoscrollToBottom} canPin={canPin} + closeReactionSelectorOnClick={closeReactionSelectorOnClick} customMessageActions={props.customMessageActions} disableQuotedMessages={props.disableQuotedMessages} endOfGroup={props.endOfGroup} @@ -299,7 +286,6 @@ export const Message = < handleRetry={handleRetry} highlighted={highlighted} initialMessage={props.initialMessage} - isReactionEnabled={isReactionEnabled} lastReceivedId={props.lastReceivedId} message={message} Message={props.Message} @@ -308,15 +294,12 @@ export const Message = < mutes={mutes} onMentionsClickMessage={onMentionsClick} onMentionsHoverMessage={onMentionsHover} - onReactionListClick={onReactionListClick} onUserClick={props.onUserClick} onUserHover={props.onUserHover} pinPermissions={props.pinPermissions} reactionDetailsSort={reactionDetailsSort} - reactionSelectorRef={reactionSelectorRef} readBy={props.readBy} renderText={props.renderText} - showDetailedReactions={showDetailedReactions} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} threadList={props.threadList} diff --git a/src/components/Message/MessageOptions.tsx b/src/components/Message/MessageOptions.tsx index 760bd1c46..3da40fcb8 100644 --- a/src/components/Message/MessageOptions.tsx +++ b/src/components/Message/MessageOptions.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import React from 'react'; import { @@ -6,13 +7,14 @@ import { ThreadIcon as DefaultThreadIcon, } from './icons'; import { MESSAGE_ACTIONS } from './utils'; - import { MessageActions } from '../MessageActions'; +import { useDialogIsOpen } from '../Dialog'; +import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton'; -import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; +import { useMessageContext, useTranslationContext } from '../../context'; import type { DefaultStreamChatGenerics, IconProps } from '../../types/types'; -import { useTranslationContext } from '../../context'; +import type { MessageContextValue } from '../../context/MessageContext'; export type MessageOptionsProps< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics @@ -21,8 +23,6 @@ export type MessageOptionsProps< ActionsIcon?: React.ComponentType; /* If true, show the `ThreadIcon` and enable navigation into a `Thread` component. */ displayReplies?: boolean; - /* React mutable ref that can be placed on the message root `div` of MessageActions component */ - messageWrapperRef?: React.RefObject; /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ ReactionIcon?: React.ComponentType; /* Theme string to be added to CSS class names. */ @@ -40,7 +40,6 @@ const UnMemoizedMessageOptions = < ActionsIcon = DefaultActionsIcon, displayReplies = true, handleOpenThread: propHandleOpenThread, - messageWrapperRef, ReactionIcon = DefaultReactionIcon, theme = 'simple', ThreadIcon = DefaultThreadIcon, @@ -51,13 +50,12 @@ const UnMemoizedMessageOptions = < handleOpenThread: contextHandleOpenThread, initialMessage, message, - onReactionListClick, - showDetailedReactions, threadList, } = useMessageContext('MessageOptions'); const { t } = useTranslationContext('MessageOptions'); - + const messageActionsDialogIsOpen = useDialogIsOpen(`message-actions--${message.id}`); + const reactionSelectorDialogIsOpen = useDialogIsOpen(`reaction-selector--${message.id}`); const handleOpenThread = propHandleOpenThread || contextHandleOpenThread; const messageActions = getMessageActions(); @@ -78,11 +76,15 @@ const UnMemoizedMessageOptions = < return null; } - const rootClassName = `str-chat__message-${theme}__actions str-chat__message-options`; - return ( -
- +
+ {shouldShowReplies && ( + )}
); diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx index bf8ade888..4b6746446 100644 --- a/src/components/Message/MessageSimple.tsx +++ b/src/components/Message/MessageSimple.tsx @@ -23,10 +23,7 @@ import { CUSTOM_MESSAGE_TYPE } from '../../constants/messageTypes'; import { EditMessageForm as DefaultEditMessageForm, MessageInput } from '../MessageInput'; import { MML } from '../MML'; import { Modal } from '../Modal'; -import { - ReactionsList as DefaultReactionList, - ReactionSelector as DefaultReactionSelector, -} from '../Reactions'; +import { ReactionsList as DefaultReactionList } from '../Reactions'; import { MessageBounceModal } from '../MessageBounce/MessageBounceModal'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; @@ -58,13 +55,10 @@ const MessageSimpleWithContext = < handleRetry, highlighted, isMyMessage, - isReactionEnabled, message, onUserClick, onUserHover, - reactionSelectorRef, renderText, - showDetailedReactions, threadList, } = props; @@ -82,7 +76,7 @@ const MessageSimpleWithContext = < MessageRepliesCountButton = DefaultMessageRepliesCountButton, MessageStatus = DefaultMessageStatus, MessageTimestamp = DefaultMessageTimestamp, - ReactionSelector = DefaultReactionSelector, + ReactionsList = DefaultReactionList, PinIndicator, } = useComponentContext('MessageSimple'); @@ -98,14 +92,6 @@ const MessageSimpleWithContext = < return ; } - /** FIXME: isReactionEnabled should be removed with next major version and a proper centralized permissions logic should be put in place - * With the current permissions implementation it would be sth like: - * const messageActions = getMessageActions(); - * const canReact = messageActions.includes(MESSAGE_ACTIONS.react); - */ - const canReact = isReactionEnabled; - const canShowReactions = hasReactions; - const showMetadata = !groupedByUser || endOfGroup; const showReplyCountButton = !threadList && !!message.reply_count; const allowRetry = message.status === 'failed' && message.errorStatusCode !== 403; @@ -134,7 +120,7 @@ const MessageSimpleWithContext = < 'str-chat__message--has-attachment': hasAttachment, 'str-chat__message--highlighted': highlighted, 'str-chat__message--pinned pinned-message': message.pinned, - 'str-chat__message--with-reactions': canShowReactions, + 'str-chat__message--with-reactions': hasReactions, 'str-chat__message-send-can-be-retried': message?.status === 'failed' && message?.errorStatusCode !== 403, 'str-chat__message-with-thread-link': showReplyCountButton, @@ -187,8 +173,7 @@ const MessageSimpleWithContext = < >
- {canShowReactions && } - {showDetailedReactions && canReact && } + {hasReactions && }
{message.attachments?.length && !message.quoted_message ? ( diff --git a/src/components/Message/__tests__/MessageOptions.test.js b/src/components/Message/__tests__/MessageOptions.test.js index fb3ce9b8a..b744bc2ad 100644 --- a/src/components/Message/__tests__/MessageOptions.test.js +++ b/src/components/Message/__tests__/MessageOptions.test.js @@ -1,6 +1,6 @@ /* eslint-disable jest-dom/prefer-to-have-class */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Message } from '../Message'; @@ -9,11 +9,15 @@ import { MessageSimple } from '../MessageSimple'; import { ACTIONS_NOT_WORKING_IN_THREAD, MESSAGE_ACTIONS } from '../utils'; import { Attachment } from '../../Attachment'; +import { defaultReactionOptions } from '../../Reactions'; -import { ChannelActionProvider } from '../../../context/ChannelActionContext'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; -import { ChatProvider } from '../../../context/ChatContext'; -import { ComponentProvider } from '../../../context/ComponentContext'; +import { + ChannelActionProvider, + ChannelStateProvider, + ChatProvider, + ComponentProvider, + DialogManagerProvider, +} from '../../../context'; import { generateChannel, @@ -30,12 +34,9 @@ const defaultMessageProps = { initialMessage: false, message: generateMessage(), messageActions: Object.keys(MESSAGE_ACTIONS), - onReactionListClick: () => {}, threadList: false, }; -const defaultOptionsProps = { - messageWrapperRef: { current: document.createElement('div') }, -}; +const defaultOptionsProps = {}; function generateAliceMessage(messageOptions) { return generateMessage({ @@ -55,32 +56,30 @@ async function renderMessageOptions({ return render( - - - + + ( - - ), + openThread: jest.fn(), + removeMessage: jest.fn(), + updateMessage: jest.fn(), }} > - - - - - - + , + reactionOptions: defaultReactionOptions, + }} + > + + + + + + + , ); } @@ -181,6 +180,85 @@ describe('', () => { expect(queryByTestId(reactionActionTestId)).not.toBeInTheDocument(); }); + it('should not render ReactionsSelector until open', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + expect(screen.getByTestId('reaction-selector')).toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed by click on dialog overlay', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.getByTestId('str-chat__dialog-overlay')); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed pressed Esc button', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: true, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).not.toBeInTheDocument(); + }); + + it('should not unmount ReactionsSelector when closed on reaction selection and closeReactionSelectorOnClick enabled', async () => { + const { queryByTestId } = await renderMessageOptions({ + channelStateOpts: { + channelCapabilities: { 'send-reaction': true }, + }, + customMessageProps: { + closeReactionSelectorOnClick: false, + }, + }); + await act(async () => { + await fireEvent.click(queryByTestId(reactionActionTestId)); + }); + await act(async () => { + await fireEvent.click(screen.queryAllByTestId('select-reaction-button')[0]); + }); + expect(screen.queryByTestId('reaction-selector')).toBeInTheDocument(); + }); + it('should render message actions', async () => { const { queryByTestId } = await renderMessageOptions({ channelStateOpts: { channelCapabilities: minimumCapabilitiesToRenderMessageActions }, diff --git a/src/components/Message/__tests__/MessageText.test.js b/src/components/Message/__tests__/MessageText.test.js index 0a6fe5638..4f561c2a9 100644 --- a/src/components/Message/__tests__/MessageText.test.js +++ b/src/components/Message/__tests__/MessageText.test.js @@ -43,8 +43,6 @@ const onMentionsClickMock = jest.fn(); const defaultProps = { initialMessage: false, message: generateMessage(), - messageWrapperRef: { current: document.createElement('div') }, - onReactionListClick: () => {}, threadList: false, }; diff --git a/src/components/Message/__tests__/QuotedMessage.test.js b/src/components/Message/__tests__/QuotedMessage.test.js index 67c9cf6ae..64f8beda8 100644 --- a/src/components/Message/__tests__/QuotedMessage.test.js +++ b/src/components/Message/__tests__/QuotedMessage.test.js @@ -9,6 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + DialogManagerProvider, TranslationProvider, } from '../../../context'; import { @@ -65,9 +66,11 @@ async function renderQuotedMessage(customProps) { Message: () => , }} > - - - + + + + + diff --git a/src/components/Message/hooks/__tests__/useReactionHandler.test.js b/src/components/Message/hooks/__tests__/useReactionHandler.test.js index 04a03f1c4..3b61291ff 100644 --- a/src/components/Message/hooks/__tests__/useReactionHandler.test.js +++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.js @@ -1,11 +1,7 @@ import React from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-hooks'; -import { - reactionHandlerWarning, - useReactionClick, - useReactionHandler, -} from '../useReactionHandler'; +import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandler'; import { ChannelActionProvider } from '../../../../context/ChannelActionContext'; import { ChannelStateProvider } from '../../../../context/ChannelStateContext'; @@ -123,192 +119,3 @@ describe('useReactionHandler custom hook', () => { expect(updateMessage).toHaveBeenCalledWith(message); }); }); - -function renderUseReactionClickHook( - message = generateMessage(), - reactionListRef = React.createRef(), - messageWrapperRef = React.createRef(), -) { - const channel = generateChannel(); - - const wrapper = ({ children }) => ( - - {children} - - ); - - const { rerender, result } = renderHook( - () => useReactionClick(message, reactionListRef, messageWrapperRef), - { wrapper }, - ); - return { rerender, result }; -} - -describe('useReactionClick custom hook', () => { - beforeEach(jest.clearAllMocks); - it('should initialize a click handler and a flag for showing detailed reactions', () => { - const { - result: { current }, - } = renderUseReactionClickHook(); - - expect(typeof current.onReactionListClick).toBe('function'); - expect(current.showDetailedReactions).toBe(false); - }); - - it('should set show details to true on click', async () => { - const { result } = renderUseReactionClickHook(); - expect(result.current.showDetailedReactions).toBe(false); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - }); - - it('should return correct value for isReactionEnabled', () => { - const channel = generateChannel(); - const channelCapabilities = { 'send-reaction': true }; - - const { rerender, result } = renderHook( - () => useReactionClick(generateMessage(), React.createRef(), React.createRef()), - { - // eslint-disable-next-line react/display-name - wrapper: ({ children }) => ( - - {children} - - ), - }, - ); - - expect(result.current.isReactionEnabled).toBe(true); - channelCapabilities['send-reaction'] = false; - rerender(); - expect(result.current.isReactionEnabled).toBe(false); - channelCapabilities['send-reaction'] = true; - rerender(); - expect(result.current.isReactionEnabled).toBe(true); - }); - - it('should set event listener to close reaction list on document click when list is opened', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - expect(document.addEventListener).toHaveBeenCalledTimes(1); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(false); - addEventListenerSpy.mockRestore(); - }); - - it('should set event listener to message wrapper reference when one is set', async () => { - const mockMessageWrapperReference = { - current: { - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - }, - }; - const { result } = renderUseReactionClickHook( - generateMessage(), - React.createRef(), - mockMessageWrapperReference, - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(mockMessageWrapperReference.current.addEventListener).toHaveBeenCalledWith( - 'mouseleave', - expect.any(Function), - ); - }); - - it('should not close reaction list on document click when click is on the reaction list itself', async () => { - const message = generateMessage(); - const reactionSelectorEl = document.createElement('div'); - const reactionListElement = document.createElement('div').appendChild(reactionSelectorEl); - const clickMock = { - target: reactionSelectorEl, - }; - const { result } = renderUseReactionClickHook(message, { - current: reactionListElement, - }); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - await act(() => { - onDocumentClick(clickMock); - }); - expect(result.current.showDetailedReactions).toBe(true); - addEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners after reaction list is closed', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const { result } = renderUseReactionClickHook(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - await act(() => { - result.current.onReactionListClick(); - }); - expect(result.current.showDetailedReactions).toBe(true); - act(() => onDocumentClick(clickMock)); - expect(result.current.showDetailedReactions).toBe(false); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); - - it('should remove close click event listeners if message is deleted', async () => { - const clickMock = { - target: document.createElement('div'), - }; - const message = generateMessage(); - let onDocumentClick; - const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation( - jest.fn((_, fn) => { - onDocumentClick = fn; - }), - ); - const removeEventListenerSpy = jest - .spyOn(document, 'removeEventListener') - .mockImplementationOnce(jest.fn()); - const { rerender, result } = renderUseReactionClickHook(message); - expect(document.removeEventListener).not.toHaveBeenCalled(); - await act(() => { - result.current.onReactionListClick(clickMock); - }); - message.deleted_at = new Date(); - rerender(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick); - addEventListenerSpy.mockRestore(); - removeEventListenerSpy.mockRestore(); - }); -}); diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts index 20795c3b1..271421342 100644 --- a/src/components/Message/hooks/useReactionHandler.ts +++ b/src/components/Message/hooks/useReactionHandler.ts @@ -1,12 +1,10 @@ -import React, { RefObject, useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback } from 'react'; import throttle from 'lodash.throttle'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { StreamMessage, useChannelStateContext } from '../../../context/ChannelStateContext'; import { useChatContext } from '../../../context/ChatContext'; -import type { ReactEventHandler } from '../types'; - import type { Reaction, ReactionResponse } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -149,100 +147,3 @@ export const useReactionHandler = < } }; }; - -export const useReactionClick = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->( - message?: StreamMessage, - reactionSelectorRef?: RefObject, - messageWrapperRef?: RefObject, - closeReactionSelectorOnClick?: boolean, -) => { - const { channelCapabilities = {} } = useChannelStateContext( - 'useReactionClick', - ); - - const [showDetailedReactions, setShowDetailedReactions] = useState(false); - - const hasListener = useRef(false); - - const isReactionEnabled = channelCapabilities['send-reaction']; - - const messageDeleted = !!message?.deleted_at; - - const closeDetailedReactions: EventListener = useCallback( - (event) => { - if ( - event.target instanceof HTMLElement && - reactionSelectorRef?.current?.contains(event.target) && - !closeReactionSelectorOnClick - ) { - return; - } - - setShowDetailedReactions(false); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [setShowDetailedReactions, reactionSelectorRef], - ); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (showDetailedReactions && !hasListener.current) { - hasListener.current = true; - document.addEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.addEventListener('mouseleave', closeDetailedReactions); - } - } - - if (!showDetailedReactions && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - - return () => { - if (hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }; - }, [showDetailedReactions, closeDetailedReactions, messageWrapperRef]); - - useEffect(() => { - const messageWrapper = messageWrapperRef?.current; - - if (messageDeleted && hasListener.current) { - document.removeEventListener('click', closeDetailedReactions); - - if (messageWrapper) { - messageWrapper.removeEventListener('mouseleave', closeDetailedReactions); - } - - hasListener.current = false; - } - }, [messageDeleted, closeDetailedReactions, messageWrapperRef]); - - const onReactionListClick: ReactEventHandler = (event) => { - event?.stopPropagation?.(); - setShowDetailedReactions((prev) => !prev); - }; - - return { - isReactionEnabled, - onReactionListClick, - showDetailedReactions, - }; -}; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 6842728be..ac52b7c73 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -313,6 +313,10 @@ export const areMessagePropsEqual = < return false; } + if (nextProps.closeReactionSelectorOnClick !== prevProps.closeReactionSelectorOnClick) { + return false; + } + const messagesAreEqual = areMessagesEqual(prevMessage, nextMessage); if (!messagesAreEqual) return false; diff --git a/src/components/MessageActions/MessageActions.tsx b/src/components/MessageActions/MessageActions.tsx index 174733bee..02fe9be56 100644 --- a/src/components/MessageActions/MessageActions.tsx +++ b/src/components/MessageActions/MessageActions.tsx @@ -1,24 +1,17 @@ -import React, { - ElementRef, - PropsWithChildren, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; import clsx from 'clsx'; +import React, { ElementRef, PropsWithChildren, useCallback, useRef } from 'react'; import { MessageActionsBox } from './MessageActionsBox'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; import { ActionsIcon as DefaultActionsIcon } from '../Message/icons'; import { isUserMuted, shouldRenderMessageActions } from '../Message/utils'; import { useChatContext } from '../../context/ChatContext'; import { MessageContextValue, useMessageContext } from '../../context/MessageContext'; +import { useComponentContext, useTranslationContext } from '../../context'; import type { DefaultStreamChatGenerics, IconProps } from '../../types/types'; -import { useMessageActionsBoxPopper } from './hooks'; -import { useComponentContext, useTranslationContext } from '../../context'; type MessageContextPropsToPick = | 'getMessageActions' @@ -38,8 +31,6 @@ export type MessageActionsProps< customWrapperClass?: string; /* If true, renders the wrapper component as a `span`, not a `div` */ inline?: boolean; - /* React mutable ref that can be placed on the message root `div` of MessageActions component */ - messageWrapperRef?: React.RefObject; /* Function that returns whether the message was sent by the connected user */ mine?: () => boolean; }; @@ -60,7 +51,6 @@ export const MessageActions = < handlePin: propHandlePin, inline, message: propMessage, - messageWrapperRef, mine, } = props; @@ -93,10 +83,12 @@ export const MessageActions = < const message = propMessage || contextMessage; const isMine = mine ? mine() : isMyMessage(); - const [actionsBoxOpen, setActionsBoxOpen] = useState(false); - const isMuted = useCallback(() => isUserMuted(message, mutes), [message, mutes]); + const dialogId = `message-actions--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId); + const messageActions = getMessageActions(); const renderMessageActions = shouldRenderMessageActions({ @@ -106,74 +98,41 @@ export const MessageActions = < messageActions, }); - const hideOptions = useCallback((event: MouseEvent | KeyboardEvent) => { - if (event instanceof KeyboardEvent && event.key !== 'Escape') { - return; - } - setActionsBoxOpen(false); - }, []); - const messageDeletedAt = !!message?.deleted_at; - - useEffect(() => { - if (messageWrapperRef?.current) { - messageWrapperRef.current.addEventListener('mouseleave', hideOptions); - } - }, [hideOptions, messageWrapperRef]); - - useEffect(() => { - if (messageDeletedAt) { - document.removeEventListener('click', hideOptions); - } - }, [hideOptions, messageDeletedAt]); - - useEffect(() => { - if (!actionsBoxOpen) return; - - document.addEventListener('click', hideOptions); - document.addEventListener('keyup', hideOptions); - - return () => { - document.removeEventListener('click', hideOptions); - document.removeEventListener('keyup', hideOptions); - }; - }, [actionsBoxOpen, hideOptions]); - const actionsBoxButtonRef = useRef>(null); - const { attributes, popperElementRef, styles } = useMessageActionsBoxPopper({ - open: actionsBoxOpen, - placement: isMine ? 'top-end' : 'top-start', - referenceElement: actionsBoxButtonRef.current, - }); - if (!renderMessageActions) return null; return ( - + + + - )} - {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 && ( - - )} -
+const UnMemoizedMessageActionsBox = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + props: MessageActionsBoxProps, +) => { + const { + className, + getMessageActions, + handleDelete, + handleEdit, + handleFlag, + handleMarkUnread, + handleMute, + handlePin, + isUserMuted, + mine, + open, + ...restDivProps + } = props; + + const { + CustomMessageActionsList = DefaultCustomMessageActionsList, + } = useComponentContext('MessageActionsBox'); + const { setQuotedMessage } = useChannelActionContext('MessageActionsBox'); + const { customMessageActions, message, threadList } = useMessageContext( + 'MessageActionsBox', + ); + + const { t } = useTranslationContext('MessageActionsBox'); + + const messageActions = getMessageActions(); + + const handleQuote = () => { + 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 && ( + + )}
- ); - }, -); +
+ ); +}; /** * A popup box that displays the available actions on a message, such as edit, delete, pin, etc. diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 9c7afa2d3..1e03d80b3 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -1,15 +1,19 @@ import React from 'react'; import '@testing-library/jest-dom'; import testRenderer from 'react-test-renderer'; -import { cleanup, fireEvent, render } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { MessageActions } from '../MessageActions'; import { MessageActionsBox as MessageActionsBoxMock } from '../MessageActionsBox'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; -import { ChatProvider } from '../../../context/ChatContext'; -import { MessageProvider } from '../../../context/MessageContext'; -import { TranslationProvider } from '../../../context/TranslationContext'; +import { + ChannelStateProvider, + ChatProvider, + ComponentProvider, + DialogManagerProvider, + MessageProvider, + TranslationProvider, +} from '../../../context'; import { generateMessage, getTestClient, mockTranslationContext } from '../../../mock-builders'; @@ -42,54 +46,78 @@ const messageContextValue = { const chatClient = getTestClient(); -function renderMessageActions(customProps, renderer = render) { +function renderMessageActions(customProps = {}, renderer = render) { return renderer( - - - - - - - + + + + + + + + + + + , ); } +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); beforeEach(jest.clearAllMocks); - it('should render correctly', () => { + it('should render correctly when not open', () => { const tree = renderMessageActions({}, testRenderer.create); expect(tree.toJSON()).toMatchInlineSnapshot(` -
-
- -
+ + + + +
, +
, + ] `); }); @@ -101,85 +129,61 @@ describe(' component', () => { expect(queryByTestId(messageActionsTestId)).toBeNull(); }); - it('should open message actions box on click', () => { - const { getByTestId } = renderMessageActions(); - expect(MessageActionsBoxMock).toHaveBeenCalledWith( - expect.objectContaining({ open: false }), - {}, - ); - fireEvent.click(getByTestId(messageActionsTestId)); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: true }), - {}, - ); - }); - - it('should close message actions box on icon click if already opened', () => { - const { getByTestId } = renderMessageActions(); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: false }), - {}, - ); - fireEvent.click(getByTestId(messageActionsTestId)); + it('should open message actions box on click', async () => { + renderMessageActions(); + expect(MessageActionsBoxMock).not.toHaveBeenCalled(); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + expect(dialogOverlay.children).toHaveLength(0); + await toggleOpenMessageActions(); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.click(getByTestId(messageActionsTestId)); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: false }), - {}, - ); + expect(dialogOverlay.children.length).toBeGreaterThan(0); }); - it('should close message actions box when user clicks anywhere in the document if it is already opened', () => { - const { getByRole } = renderMessageActions(); - fireEvent.click(getByRole('button')); - + it('should close message actions box on icon click if already opened', async () => { + renderMessageActions(); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + expect(MessageActionsBoxMock).not.toHaveBeenCalled(); + await toggleOpenMessageActions(); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ open: true }), {}, ); - fireEvent.click(document); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: false }), - {}, - ); + await toggleOpenMessageActions(); + expect(dialogOverlay.children).toHaveLength(0); }); - it('should close message actions box when user presses Escape key', () => { - const { getByRole } = renderMessageActions(); - fireEvent.click(getByRole('button')); + 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 }), {}, ); - fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: false }), - {}, - ); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + await act(async () => { + await fireEvent.click(dialogOverlay); + }); + expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1); + expect(dialogOverlay.children).toHaveLength(0); }); - it('should close actions box open on mouseleave if container ref provided', () => { - const customProps = { - messageWrapperRef: { current: wrapperMock }, - }; - const { getByRole } = renderMessageActions(customProps); - fireEvent.click(getByRole('button')); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: true }), - {}, - ); - fireEvent.mouseLeave(customProps.messageWrapperRef.current); - expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( - expect.objectContaining({ open: false }), - {}, - ); + it('should close message actions box when user presses Escape key', async () => { + renderMessageActions(); + const dialogOverlay = screen.getByTestId(dialogOverlayTestId); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.keyUp(document, { charCode: 27, code: 'Escape', key: 'Escape' }); + }); + expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1); + expect(dialogOverlay.children).toHaveLength(0); }); - it('should render the message actions box correctly', () => { + it('should render the message actions box correctly', async () => { renderMessageActions(); + await toggleOpenMessageActions(); expect(MessageActionsBoxMock).toHaveBeenLastCalledWith( expect.objectContaining({ getMessageActions: defaultProps.getMessageActions, @@ -190,39 +194,27 @@ describe(' component', () => { handlePin: defaultProps.handlePin, isUserMuted: expect.any(Function), mine: false, - open: false, + open: true, }), {}, ); }); - it('should not register click and keyup event listeners to close actions box until opened', () => { - const { getByRole } = renderMessageActions(); + 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(); - fireEvent.click(getByRole('button')); - expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + await toggleOpenMessageActions(); expect(document.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); addEventListener.mockClear(); }); - it('should not remove click and keyup event listeners when unmounted if actions box not opened', () => { + 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).not.toHaveBeenCalledWith('click', expect.any(Function)); - expect(document.removeEventListener).not.toHaveBeenCalledWith('keyup', expect.any(Function)); - removeEventListener.mockClear(); - }); - - it('should remove event listener when unmounted', () => { - const { getByRole, unmount } = renderMessageActions(); - const removeEventListener = jest.spyOn(document, 'removeEventListener'); - fireEvent.click(getByRole('button')); - expect(document.removeEventListener).not.toHaveBeenCalled(); - unmount(); - expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); expect(document.removeEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)); removeEventListener.mockClear(); }); @@ -235,32 +227,45 @@ describe(' component', () => { testRenderer.create, ); expect(tree.toJSON()).toMatchInlineSnapshot(` -
-
- -
+ + + + +
, +
, + ] `); }); @@ -272,32 +277,45 @@ describe(' component', () => { testRenderer.create, ); expect(tree.toJSON()).toMatchInlineSnapshot(` - -
- - + + + + + , +
, + ] `); }); }); diff --git a/src/components/MessageActions/__tests__/MessageActionsBox.test.js b/src/components/MessageActions/__tests__/MessageActionsBox.test.js index 2f786facf..6a62a5383 100644 --- a/src/components/MessageActions/__tests__/MessageActionsBox.test.js +++ b/src/components/MessageActions/__tests__/MessageActionsBox.test.js @@ -18,7 +18,7 @@ import { import { Message } from '../../Message'; import { Channel } from '../../Channel'; import { Chat } from '../../Chat'; -import { ChatProvider } from '../../../context'; +import { ChatProvider, ComponentProvider, DialogManagerProvider } from '../../../context'; expect.extend(toHaveNoViolations); @@ -29,24 +29,39 @@ const defaultMessageContextValue = { 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 { client } = await initClientWithChannels(); return render( key }}> - - + - - - + + + + + + + , ); @@ -72,7 +87,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['flag']); const handleFlag = jest.fn(); const { container, getByText } = await renderComponent({ handleFlag }); - fireEvent.click(getByText('Flag')); + await act(async () => { + await fireEvent.click(getByText('Flag')); + }); expect(handleFlag).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -85,7 +102,9 @@ describe('MessageActionsBox', () => { handleMute, isUserMuted: () => false, }); - fireEvent.click(getByText('Mute')); + await act(async () => { + await fireEvent.click(getByText('Mute')); + }); expect(handleMute).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -98,7 +117,9 @@ describe('MessageActionsBox', () => { handleMute, isUserMuted: () => true, }); - fireEvent.click(getByText('Unmute')); + await act(async () => { + await fireEvent.click(getByText('Unmute')); + }); expect(handleMute).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -108,7 +129,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['edit']); const handleEdit = jest.fn(); const { container, getByText } = await renderComponent({ handleEdit }); - fireEvent.click(getByText('Edit Message')); + await act(async () => { + await fireEvent.click(getByText('Edit Message')); + }); expect(handleEdit).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -118,7 +141,9 @@ describe('MessageActionsBox', () => { getMessageActionsMock.mockImplementationOnce(() => ['delete']); const handleDelete = jest.fn(); const { container, getByText } = await renderComponent({ handleDelete }); - fireEvent.click(getByText('Delete')); + await act(async () => { + await fireEvent.click(getByText('Delete')); + }); expect(handleDelete).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -129,7 +154,9 @@ describe('MessageActionsBox', () => { const handlePin = jest.fn(); const message = generateMessage({ pinned: false }); const { container, getByText } = await renderComponent({ handlePin, message }); - fireEvent.click(getByText('Pin')); + await act(async () => { + await fireEvent.click(getByText('Pin')); + }); expect(handlePin).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -140,7 +167,9 @@ describe('MessageActionsBox', () => { const handlePin = jest.fn(); const message = generateMessage({ pinned: true }); const { container, getByText } = await renderComponent({ handlePin, message }); - fireEvent.click(getByText('Unpin')); + await act(async () => { + await fireEvent.click(getByText('Unpin')); + }); expect(handlePin).toHaveBeenCalledTimes(1); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -149,7 +178,6 @@ describe('MessageActionsBox', () => { describe('mark message unread', () => { afterEach(jest.restoreAllMocks); const ACTION_TEXT = 'Mark as unread'; - const TOGGLE_ACTIONS_BUTTON_TEST_ID = 'message-actions'; const me = generateUser(); const otherUser = generateUser(); const message = generateMessage({ user: otherUser }); @@ -195,16 +223,18 @@ describe('MessageActionsBox', () => { 'upload-file', ]; const renderMarkUnreadUI = async ({ channelProps, chatProps, messageProps }) => - await act(() => { - render( + await act(async () => { + await render( - + + + , ); @@ -230,9 +260,7 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - }); + await toggleOpenMessageActions(); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); @@ -257,9 +285,7 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message: myMessage }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - }); + await toggleOpenMessageActions(); expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); }); @@ -277,9 +303,7 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message, threadList: true }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - }); + await toggleOpenMessageActions(); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); @@ -312,9 +336,7 @@ describe('MessageActionsBox', () => { }); }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - }); + await toggleOpenMessageActions(); expect(screen.queryByText(ACTION_TEXT)).toBeInTheDocument(); }); @@ -341,20 +363,18 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message: messageWithoutID }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - }); + await toggleOpenMessageActions(); expect(screen.queryByText(ACTION_TEXT)).not.toBeInTheDocument(); }); - it('should be displayed as an option for messages other than message marked unread', async () => { + 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() + 1000), + created_at: new Date(new Date(message.created_at).getTime() + 2000), }); const read = [ { - first_unread_message_id: message.id, - last_read: new Date(new Date(message.created_at).getTime() - 1000), + 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, @@ -374,20 +394,29 @@ describe('MessageActionsBox', () => { customUser: me, }); - await act(() => { - render( + await act(async () => { + await render( - - + + + + , ); }); - - const [actionsBox1, actionsBox2] = screen.getAllByTestId('message-actions-box'); - expect(actionsBox1).toHaveTextContent(ACTION_TEXT); - expect(actionsBox2).toHaveTextContent(ACTION_TEXT); + await toggleOpenMessageActions(0); + let boxes = screen.getAllByTestId('message-actions-box'); + // eslint-disable-next-line jest-dom/prefer-in-document + expect(boxes).toHaveLength(1); + expect(boxes[0]).toHaveTextContent(ACTION_TEXT); + + await toggleOpenMessageActions(1); + boxes = screen.getAllByTestId('message-actions-box'); + // eslint-disable-next-line jest-dom/prefer-in-document + expect(boxes).toHaveLength(1); + expect(boxes[0]).toHaveTextContent(ACTION_TEXT); }); it('should be displayed and execute API request', async () => { @@ -405,9 +434,10 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(channel.markUnread).toHaveBeenCalledWith( expect.objectContaining({ message_id: message.id }), @@ -430,9 +460,10 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { getMarkMessageUnreadSuccessNotification, message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith( expect.objectContaining(message), @@ -455,9 +486,10 @@ describe('MessageActionsBox', () => { chatProps: { client }, messageProps: { getMarkMessageUnreadErrorNotification, message }, }); - await act(() => { - fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); - fireEvent.click(screen.getByText(ACTION_TEXT)); + await toggleOpenMessageActions(); + await act(async () => { + await fireEvent.click(screen.getByTestId(TOGGLE_ACTIONS_BUTTON_TEST_ID)); + await fireEvent.click(screen.getByText(ACTION_TEXT)); }); expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith( expect.objectContaining(message), diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index a2a14eb97..9a244c901 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -21,6 +21,7 @@ import { ChannelStateContextValue, useChannelStateContext, } from '../../context/ChannelStateContext'; +import { DialogManagerProvider } from '../../context'; import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageListContextProvider } from '../../context/MessageListContext'; @@ -223,44 +224,46 @@ const MessageListWithContext = < return ( - {!threadList && showUnreadMessagesNotification && ( - - )} -
- {showEmptyStateIndicator ? ( - - ) : ( - - {props.loadingMore && } -
- } - loadNextPage={loadMoreNewer} - loadPreviousPage={loadMore} - threshold={loadMoreScrollThreshold} - {...restInternalInfiniteScrollProps} - > -
    - {elements} -
- - -
- + + {!threadList && showUnreadMessagesNotification && ( + )} -
+
+ {showEmptyStateIndicator ? ( + + ) : ( + + {props.loadingMore && } +
+ } + loadNextPage={loadMoreNewer} + loadPreviousPage={loadMore} + threshold={loadMoreScrollThreshold} + {...restInternalInfiniteScrollProps} + > +
    + {elements} +
+ + +
+ + )} +
+
- {!threadList && showUnreadMessagesNotification && ( - - )} -
- > - atBottomStateChange={atBottomStateChange} - atBottomThreshold={100} - atTopStateChange={atTopStateChange} - atTopThreshold={100} - className='str-chat__message-list-scroll' - components={{ - EmptyPlaceholder, - Header, - Item, - ...virtuosoComponentsFromProps, - }} - computeItemKey={computeItemKey} - context={{ - additionalMessageInputProps, - closeReactionSelectorOnClick, - customClasses, - customMessageActions, - customMessageRenderer, - DateSeparator, - firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, - formatDate, - head, - lastReadDate: channelUnreadUiState?.last_read, - lastReadMessageId: channelUnreadUiState?.last_read_message_id, - lastReceivedMessageId, - loadingMore, - Message: MessageUIComponent, - messageActions, - messageGroupStyles, - MessageSystem, - numItemsPrepended, - ownMessagesReadByOthers, - processedMessages, - reactionDetailsSort, - shouldGroupByUser, - sortReactionDetails, - sortReactions, - threadList, - unreadMessageCount: channelUnreadUiState?.unread_messages, - UnreadMessagesSeparator, - virtuosoRef: virtuoso, - }} - firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} - followOutput={followOutput} - increaseViewportBy={{ bottom: 200, top: 0 }} - initialTopMostItemIndex={calculateInitialTopMostItemIndex( - processedMessages, - highlightedMessageId, - )} - itemContent={messageRenderer} - itemSize={fractionalItemSize} - itemsRendered={handleItemsRendered} - key={messageSetKey} - overscan={overscan} - ref={virtuoso} - style={{ overflowX: 'hidden' }} - totalCount={processedMessages.length} - {...overridingVirtuosoProps} - {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} - {...(defaultItemHeight ? { defaultItemHeight } : {})} - /> -
+ + {!threadList && showUnreadMessagesNotification && ( + + )} +
+ > + atBottomStateChange={atBottomStateChange} + atBottomThreshold={100} + atTopStateChange={atTopStateChange} + atTopThreshold={100} + className='str-chat__message-list-scroll' + components={{ + EmptyPlaceholder, + Header, + Item, + ...virtuosoComponentsFromProps, + }} + computeItemKey={computeItemKey} + context={{ + additionalMessageInputProps, + closeReactionSelectorOnClick, + customClasses, + customMessageActions, + customMessageRenderer, + DateSeparator, + firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, + formatDate, + head, + lastReadDate: channelUnreadUiState?.last_read, + lastReadMessageId: channelUnreadUiState?.last_read_message_id, + lastReceivedMessageId, + loadingMore, + Message: MessageUIComponent, + messageActions, + messageGroupStyles, + MessageSystem, + numItemsPrepended, + ownMessagesReadByOthers, + processedMessages, + reactionDetailsSort, + shouldGroupByUser, + sortReactionDetails, + sortReactions, + threadList, + unreadMessageCount: channelUnreadUiState?.unread_messages, + UnreadMessagesSeparator, + virtuosoRef: virtuoso, + }} + firstItemIndex={calculateFirstItemIndex(numItemsPrepended)} + followOutput={followOutput} + increaseViewportBy={{ bottom: 200, top: 0 }} + initialTopMostItemIndex={calculateInitialTopMostItemIndex( + processedMessages, + highlightedMessageId, + )} + itemContent={messageRenderer} + itemSize={fractionalItemSize} + itemsRendered={handleItemsRendered} + key={messageSetKey} + overscan={overscan} + ref={virtuoso} + style={{ overflowX: 'hidden' }} + totalCount={processedMessages.length} + {...overridingVirtuosoProps} + {...(scrollSeekPlaceHolder ? { scrollSeek: scrollSeekPlaceHolder } : {})} + {...(defaultItemHeight ? { defaultItemHeight } : {})} + /> +
+
{TypingIndicator && }
( - {children} + + + {children} + + @@ -83,7 +88,16 @@ describe('VirtualizedMessageComponents', () => { const CustomLoadingIndicator = () =>
Custom Loading Indicator
; it('should render empty div in Header when not loading more messages', () => { const { container } = renderElements(
); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render LoadingIndicator in Header when loading more messages', () => { @@ -105,6 +119,12 @@ describe('VirtualizedMessageComponents', () => { Custom Loading Indicator
+
`); }); @@ -112,7 +132,16 @@ describe('VirtualizedMessageComponents', () => { it('should not render custom LoadingIndicator in Header when not loading more messages', () => { const componentContext = { LoadingIndicator: CustomLoadingIndicator }; const { container } = renderElements(
, componentContext); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); // FIXME: this is a crazy pattern of having to set LoadingIndicator to null so that additionalVirtuosoProps.head can be rendered. @@ -134,6 +163,12 @@ describe('VirtualizedMessageComponents', () => {
Custom head
+
`); }); @@ -146,6 +181,12 @@ describe('VirtualizedMessageComponents', () => {
Custom head
+
`); }); @@ -166,6 +207,12 @@ describe('VirtualizedMessageComponents', () => { Custom Loading Indicator
+
`); }); @@ -184,7 +231,16 @@ describe('VirtualizedMessageComponents', () => { it('should render empty for thread by default', () => { const { container } = renderElements(); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render custom EmptyStateIndicator for main message list', () => { const { container } = renderElements(, componentContext); @@ -202,7 +258,16 @@ describe('VirtualizedMessageComponents', () => { it('should render empty if EmptyStateIndicator nullified', () => { const componentContext = { EmptyStateIndicator: NullEmptyStateIndicator }; const { container } = renderElements(, componentContext); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); it('should render empty in thread if EmptyStateIndicator nullified', () => { @@ -211,7 +276,16 @@ describe('VirtualizedMessageComponents', () => { , componentContext, ); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); }); }); diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap index 19b10d30c..7c708d272 100644 --- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap +++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap @@ -67,6 +67,17 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
+
Custom EmptyStateIndicator
+
`; @@ -17,6 +23,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt > Custom EmptyStateIndicator
+
`; @@ -45,6 +57,12 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me No chats here yet…

+
`; @@ -97,6 +115,12 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
+
`; @@ -146,6 +170,12 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
+
`; @@ -155,6 +185,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla class="XXX" data-item-index="10000000" /> +
`; @@ -164,6 +200,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla class="XXX" data-item-index="10000000" /> +
`; @@ -173,6 +215,12 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom class="str-chat__virtual-list-message-wrapper str-chat__li str-chat__li--single" data-item-index="10000000" /> +
`; @@ -182,5 +230,11 @@ exports[`VirtualizedMessageComponents Item should render wrapper without custom class="str-chat__virtual-list-message-wrapper str-chat__li" data-item-index="10000000" /> +
`; diff --git a/src/components/Reactions/ReactionSelector.tsx b/src/components/Reactions/ReactionSelector.tsx index 4c5875ed0..ceeea4c4b 100644 --- a/src/components/Reactions/ReactionSelector.tsx +++ b/src/components/Reactions/ReactionSelector.tsx @@ -1,14 +1,15 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; - -import { isMutableRef } from './utils/utils'; import { Avatar as DefaultAvatar } from '../Avatar'; +import { useDialog } from '../Dialog'; +import { defaultReactionOptions } from './reactionOptions'; +import { isMutableRef } from './utils/utils'; + import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; -import { defaultReactionOptions } from './reactionOptions'; +import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import type { AvatarProps } from '../Avatar'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; @@ -42,181 +43,191 @@ export type ReactionSelectorProps< reverse?: boolean; }; -const UnMemoizedReactionSelector = React.forwardRef( - ( - props: ReactionSelectorProps, - ref: React.ForwardedRef, - ) => { - const { - Avatar: propAvatar, - detailedView = true, - handleReaction: propHandleReaction, - latest_reactions: propLatestReactions, - own_reactions: propOwnReactions, - reaction_groups: propReactionGroups, - reactionOptions: propReactionOptions, - reverse = false, - } = props; - - const { - Avatar: contextAvatar, - reactionOptions: contextReactionOptions = defaultReactionOptions, - } = useComponentContext('ReactionSelector'); - const { - handleReaction: contextHandleReaction, - message, - } = useMessageContext('ReactionSelector'); - - const reactionOptions = propReactionOptions ?? contextReactionOptions; - - const Avatar = propAvatar || contextAvatar || DefaultAvatar; - const handleReaction = propHandleReaction || contextHandleReaction; - const latestReactions = propLatestReactions || message?.latest_reactions || []; - const ownReactions = propOwnReactions || message?.own_reactions || []; - const reactionGroups = propReactionGroups || message?.reaction_groups || {}; - - const [tooltipReactionType, setTooltipReactionType] = useState(null); - const [tooltipPositions, setTooltipPositions] = useState<{ - arrow: number; - tooltip: number; - } | null>(null); - - const targetRef = useRef(null); - const tooltipRef = useRef(null); - - const showTooltip = useCallback( - (event: React.MouseEvent, reactionType: string) => { - targetRef.current = event.currentTarget; - setTooltipReactionType(reactionType); - }, - [], - ); - - const hideTooltip = useCallback(() => { - setTooltipReactionType(null); - setTooltipPositions(null); - }, []); - - useEffect(() => { - if (tooltipReactionType) { - const tooltip = tooltipRef.current?.getBoundingClientRect(); - const target = targetRef.current?.getBoundingClientRect(); - - const container = isMutableRef(ref) ? ref.current?.getBoundingClientRect() : null; - - if (!tooltip || !target || !container) return; - - const tooltipPosition = - tooltip.width === container.width || tooltip.x < container.x - ? 0 - : target.left + target.width / 2 - container.left - tooltip.width / 2; - - const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; - - setTooltipPositions({ - arrow: arrowPosition, - tooltip: tooltipPosition, - }); - } - }, [tooltipReactionType, ref]); - - const getUsersPerReactionType = (type: string | null) => - latestReactions - .map((reaction) => { - if (reaction.type === type) { - return reaction.user?.name || reaction.user?.id; - } - return null; - }) - .filter(Boolean); - - const iHaveReactedWithReaction = (reactionType: string) => - ownReactions.find((reaction) => reaction.type === reactionType); - - const getLatestUserForReactionType = (type: string | null) => - latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || - undefined; - - return ( -
( + props: ReactionSelectorProps, +) => { + const { + Avatar: propAvatar, + detailedView = true, + handleReaction: propHandleReaction, + latest_reactions: propLatestReactions, + own_reactions: propOwnReactions, + reaction_groups: propReactionGroups, + reactionOptions: propReactionOptions, + reverse = false, + } = props; + + const { + Avatar: contextAvatar, + reactionOptions: contextReactionOptions = defaultReactionOptions, + } = useComponentContext('ReactionSelector'); + const { + closeReactionSelectorOnClick, + handleReaction: contextHandleReaction, + message, + } = useMessageContext('ReactionSelector'); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const reactionOptions = propReactionOptions ?? contextReactionOptions; + + const Avatar = propAvatar || contextAvatar || DefaultAvatar; + const handleReaction = propHandleReaction || contextHandleReaction; + const latestReactions = propLatestReactions || message?.latest_reactions || []; + const ownReactions = propOwnReactions || message?.own_reactions || []; + const reactionGroups = propReactionGroups || message?.reaction_groups || {}; + + const [tooltipReactionType, setTooltipReactionType] = useState(null); + const [tooltipPositions, setTooltipPositions] = useState<{ + arrow: number; + tooltip: number; + } | null>(null); + + const rootRef = useRef(null); + const targetRef = useRef(null); + const tooltipRef = useRef(null); + + const showTooltip = useCallback( + (event: React.MouseEvent, reactionType: string) => { + targetRef.current = event.currentTarget; + setTooltipReactionType(reactionType); + }, + [], + ); + + const hideTooltip = useCallback(() => { + setTooltipReactionType(null); + setTooltipPositions(null); + }, []); + + useEffect(() => { + if (!tooltipReactionType || !rootRef.current) return; + const tooltip = tooltipRef.current?.getBoundingClientRect(); + const target = targetRef.current?.getBoundingClientRect(); + + const container = isMutableRef(rootRef) ? rootRef.current?.getBoundingClientRect() : null; + + if (!tooltip || !target || !container) return; + + const tooltipPosition = + tooltip.width === container.width || tooltip.x < container.x + ? 0 + : target.left + target.width / 2 - container.left - tooltip.width / 2; + + const arrowPosition = target.x - tooltip.x + target.width / 2 - tooltipPosition; + + setTooltipPositions({ + arrow: arrowPosition, + tooltip: tooltipPosition, + }); + }, [tooltipReactionType, rootRef]); + + const getUsersPerReactionType = (type: string | null) => + latestReactions + .map((reaction) => { + if (reaction.type === type) { + return reaction.user?.name || reaction.user?.id; + } + return null; + }) + .filter(Boolean); + + const iHaveReactedWithReaction = (reactionType: string) => + ownReactions.find((reaction) => reaction.type === reactionType); + + const getLatestUserForReactionType = (type: string | null) => + latestReactions.find((reaction) => reaction.type === type && !!reaction.user)?.user || + undefined; + + return ( +
- {!!tooltipReactionType && detailedView && ( -
-
- {getUsersPerReactionType(tooltipReactionType)?.map((user, i, users) => ( - - {`${user}${i < users.length - 1 ? ', ' : ''}`} - - ))} -
- )} -
    - {reactionOptions.map(({ Component, name: reactionName, type: reactionType }) => { - const latestUser = getLatestUserForReactionType(reactionType); - const count = reactionGroups[reactionType]?.count ?? 0; - return ( -
  • - -
  • - ); - })} -
-
- ); - }, -); + )} + + + ); + })} + +
+ ); +}; /** * Component that allows a user to select a reaction. diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx new file mode 100644 index 000000000..86513ee65 --- /dev/null +++ b/src/components/Reactions/ReactionSelectorWithButton.tsx @@ -0,0 +1,54 @@ +import React, { ElementRef, useRef } from 'react'; +import { ReactionSelector as DefaultReactionSelector } from './ReactionSelector'; +import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; +import { useComponentContext, useMessageContext, useTranslationContext } from '../../context'; +import type { DefaultStreamChatGenerics } from '../../types'; +import type { IconProps } from '../../types/types'; + +type ReactionSelectorWithButtonProps = { + /* Custom component rendering the icon used in a button invoking reactions selector for a given message. */ + ReactionIcon: React.ComponentType; + /* Theme string to be added to CSS class names. */ + theme: string; +}; + +/** + * Internal convenience component - not to be exported. It just groups the button and the dialog anchor and thus prevents + * cluttering the parent component. + */ +export const ReactionSelectorWithButton = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + ReactionIcon, + theme, +}: ReactionSelectorWithButtonProps) => { + const { t } = useTranslationContext('ReactionSelectorWithButton'); + const { isMyMessage, message } = useMessageContext('MessageOptions'); + const { ReactionSelector = DefaultReactionSelector } = useComponentContext('MessageOptions'); + const buttonRef = useRef>(null); + const dialogId = `reaction-selector--${message.id}`; + const dialog = useDialog({ id: dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId); + return ( + <> + + + + + + ); +}; diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx index d5974854a..c03025e44 100644 --- a/src/components/Reactions/ReactionsList.tsx +++ b/src/components/Reactions/ReactionsList.tsx @@ -4,8 +4,6 @@ import clsx from 'clsx'; import type { ReactionGroupResponse, ReactionResponse } from 'stream-chat'; import { useProcessReactions } from './hooks/useProcessReactions'; - -import type { ReactEventHandler } from '../Message/types'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ReactionOptions } from './reactionOptions'; import type { ReactionDetailsComparator, ReactionsComparator, ReactionType } from './types'; @@ -18,8 +16,6 @@ export type ReactionsListProps< > = Partial< Pick, 'handleFetchReactions' | 'reactionDetailsSort'> > & { - /** Custom on click handler for an individual reaction, defaults to `onReactionListClick` from the `MessageContext` */ - onClick?: ReactEventHandler; /** An array of the own reaction objects to distinguish own reactions visually */ own_reactions?: ReactionResponse[]; /** diff --git a/src/components/Reactions/__tests__/ReactionSelector.test.js b/src/components/Reactions/__tests__/ReactionSelector.test.js index 500d9d9e4..3b668ea76 100644 --- a/src/components/Reactions/__tests__/ReactionSelector.test.js +++ b/src/components/Reactions/__tests__/ReactionSelector.test.js @@ -13,8 +13,9 @@ import { Avatar as AvatarMock } from '../../Avatar'; import { ComponentProvider } from '../../../context/ComponentContext'; import { MessageProvider } from '../../../context/MessageContext'; +import { DialogManagerProvider } from '../../../context'; -import { generateReaction, generateUser } from '../../../mock-builders'; +import { generateMessage, generateReaction, generateUser } from '../../../mock-builders'; jest.mock('../../Avatar', () => ({ Avatar: jest.fn(() =>
), @@ -35,11 +36,13 @@ const handleReactionMock = jest.fn(); const renderComponent = (props) => render( - - - - - , + + + + + + + , ); describe('ReactionSelector', () => { diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx index 6ede0894a..a02b1c737 100644 --- a/src/components/Thread/Thread.tsx +++ b/src/components/Thread/Thread.tsx @@ -18,7 +18,8 @@ import { useChatContext, useComponentContext, } from '../../context'; -import { useStateStore, useThreadContext } from '../../components/Threads'; +import { useThreadContext } from '../Threads'; +import { useStateStore } from '../../store'; import type { MessageProps, MessageUIComponentProps } from '../Message/types'; import type { MessageActionsArray } from '../Message/utils'; diff --git a/src/components/Threads/ThreadList/ThreadList.tsx b/src/components/Threads/ThreadList/ThreadList.tsx index fdec3a520..e397bd427 100644 --- a/src/components/Threads/ThreadList/ThreadList.tsx +++ b/src/components/Threads/ThreadList/ThreadList.tsx @@ -8,7 +8,7 @@ import { ThreadListEmptyPlaceholder as DefaultThreadListEmptyPlaceholder } from import { ThreadListUnseenThreadsBanner as DefaultThreadListUnseenThreadsBanner } from './ThreadListUnseenThreadsBanner'; import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from './ThreadListLoadingIndicator'; import { useChatContext, useComponentContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.threads] as const; diff --git a/src/components/Threads/ThreadList/ThreadListItemUI.tsx b/src/components/Threads/ThreadList/ThreadListItemUI.tsx index f64ffdc86..f1cef2dd0 100644 --- a/src/components/Threads/ThreadList/ThreadListItemUI.tsx +++ b/src/components/Threads/ThreadList/ThreadListItemUI.tsx @@ -11,7 +11,7 @@ import { UnreadCountBadge } from '../UnreadCountBadge'; import { useChatContext } from '../../../context'; import { useThreadsViewContext } from '../../ChatView'; import { useThreadListItemContext } from './ThreadListItem'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; export type ThreadListItemUIProps = ComponentPropsWithoutRef<'button'>; diff --git a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx index da9da4ea4..e778b3035 100644 --- a/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx +++ b/src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx @@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat'; import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading'; import { useChatContext, useComponentContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.pagination.isLoadingNext]; diff --git a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx index 5d2178002..c7409f5ae 100644 --- a/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx +++ b/src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx @@ -4,7 +4,7 @@ import type { ThreadManagerState } from 'stream-chat'; import { Icon } from '../icons'; import { useChatContext } from '../../../context'; -import { useStateStore } from '../hooks/useStateStore'; +import { useStateStore } from '../../../store'; const selector = (nextValue: ThreadManagerState) => [nextValue.unseenThreadIds] as const; diff --git a/src/components/Threads/hooks/useThreadManagerState.ts b/src/components/Threads/hooks/useThreadManagerState.ts index 1ee2e85b2..18ac8c7fd 100644 --- a/src/components/Threads/hooks/useThreadManagerState.ts +++ b/src/components/Threads/hooks/useThreadManagerState.ts @@ -1,6 +1,6 @@ import { useChatContext } from 'context'; -import { useStateStore } from './useStateStore'; import { ThreadManagerState } from 'stream-chat'; +import { useStateStore } from '../../../store'; export const useThreadManagerState = ( selector: (nextValue: ThreadManagerState) => T, diff --git a/src/components/Threads/hooks/useThreadState.ts b/src/components/Threads/hooks/useThreadState.ts index be02838ef..f6d8eb7a8 100644 --- a/src/components/Threads/hooks/useThreadState.ts +++ b/src/components/Threads/hooks/useThreadState.ts @@ -1,7 +1,7 @@ import { ThreadState } from 'stream-chat'; -import { useStateStore } from './useStateStore'; import { useThreadListItemContext } from '../ThreadList'; import { useThreadContext } from '../ThreadContext'; +import { useStateStore } from '../../../store/'; /** * @description returns thread state, prioritizes `ThreadListItemContext` falls back to `ThreadContext` if not former is not present diff --git a/src/components/Threads/index.ts b/src/components/Threads/index.ts index 7347139bd..454098f8c 100644 --- a/src/components/Threads/index.ts +++ b/src/components/Threads/index.ts @@ -1,3 +1,2 @@ export * from './ThreadContext'; export * from './ThreadList'; -export * from './hooks/useStateStore'; diff --git a/src/components/index.ts b/src/components/index.ts index c6bef9f87..872fd5e08 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -10,6 +10,7 @@ export * from './Chat'; export * from './ChatAutoComplete'; export * from './CommandItem'; export * from './DateSeparator'; +export * from './Dialog'; export * from './EmoticonItem'; export * from './EmptyStateIndicator'; export * from './EventComponent'; diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx new file mode 100644 index 000000000..b1f14126d --- /dev/null +++ b/src/context/DialogManagerContext.tsx @@ -0,0 +1,27 @@ +import React, { PropsWithChildren, useContext, useState } from 'react'; +import { DialogManager } from '../components/Dialog/DialogManager'; +import { DialogPortalDestination } from '../components/Dialog/DialogPortal'; + +type DialogManagerProviderContextValue = { + dialogManager: DialogManager; +}; + +const DialogManagerProviderContext = React.createContext< + DialogManagerProviderContextValue | undefined +>(undefined); + +export const DialogManagerProvider = ({ children, id }: PropsWithChildren<{ id?: string }>) => { + const [dialogManager] = useState(() => new DialogManager({ id })); + + return ( + + {children} + + + ); +}; + +export const useDialogManager = () => { + const value = useContext(DialogManagerProviderContext); + return value as DialogManagerProviderContextValue; +}; diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 6af8d11d8..3cd6bbfdf 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -70,10 +70,6 @@ export type MessageContextValue< handleRetry: ChannelActionContextValue['retrySendMessage']; /** Function that returns whether the Message belongs to the current user */ isMyMessage: () => boolean; - /** @deprecated will be removed in the next major release. - * Whether sending reactions is enabled for the active channel. - */ - isReactionEnabled: boolean; /** The message object */ message: StreamMessage; /** Indicates whether a message has not been read yet or has been marked unread */ @@ -82,22 +78,18 @@ export type MessageContextValue< onMentionsClickMessage: ReactEventHandler; /** Handler function for a hover event on an @mention in Message */ onMentionsHoverMessage: ReactEventHandler; - /** Handler function for a click event on the reaction list */ - onReactionListClick: ReactEventHandler; /** Handler function for a click event on the user that posted the Message */ onUserClick: ReactEventHandler; /** Handler function for a hover event on the user that posted the Message */ onUserHover: ReactEventHandler; - /** Ref to be placed on the reaction selector component */ - reactionSelectorRef: React.MutableRefObject; /** Function to toggle the edit state on a Message */ setEditingState: ReactEventHandler; - /** Whether or not to show reaction list details */ - showDetailedReactions: boolean; /** Additional props for underlying MessageInput component, [available props](https://getstream.io/chat/docs/sdk/react/message-input-components/message_input/#props) */ additionalMessageInputProps?: MessageInputProps; /** Call this function to keep message list scrolled to the bottom when the scroll height increases, e.g. an element appears below the last message (only used in the `VirtualizedMessageList`) */ autoscrollToBottom?: () => void; + /** Message component configuration prop. If true, picking a reaction from the `ReactionSelector` component will close the selector */ + closeReactionSelectorOnClick?: boolean; /** Object containing custom message actions and function handlers */ customMessageActions?: CustomMessageActions; /** If true, the message is the last one in a group sent by a specific user (only used in the `VirtualizedMessageList`) */ diff --git a/src/context/index.ts b/src/context/index.ts index 8a1be8302..15e3f422b 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -3,6 +3,7 @@ export * from './ChannelListContext'; export * from './ChannelStateContext'; export * from './ChatContext'; export * from './ComponentContext'; +export * from './DialogManagerContext'; export * from './MessageContext'; export * from './MessageBounceContext'; export * from './MessageInputContext'; diff --git a/src/index.ts b/src/index.ts index e5eb9f321..b86ce062d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './components'; export * from './context'; export * from './i18n'; +export * from './store'; export * from './types'; export * from './utils'; diff --git a/src/store/hooks/index.ts b/src/store/hooks/index.ts new file mode 100644 index 000000000..5a67cce00 --- /dev/null +++ b/src/store/hooks/index.ts @@ -0,0 +1 @@ +export * from './useStateStore'; diff --git a/src/components/Threads/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts similarity index 100% rename from src/components/Threads/hooks/useStateStore.ts rename to src/store/hooks/useStateStore.ts diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/yarn.lock b/yarn.lock index 229ec0df5..92b9510f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2356,10 +2356,10 @@ crypto-browserify "^3.11.0" process-es6 "^0.11.2" -"@stream-io/stream-chat-css@^5.0.0-rc.5": - version "5.0.0-rc.5" - resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.5.tgz#889218fc9c604b12d4b8d5895a7c96668d4b78fc" - integrity sha512-1NfgoJE5PC/i4aVspIsMaSbvh8rphpilAv6+zlBOCVQL/AAhSFt8QdHUGSTeqwzI7p6waiFk0pQ2bSWKTUpuFA== +"@stream-io/stream-chat-css@5.0.0-rc.6": + version "5.0.0-rc.6" + resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.0.0-rc.6.tgz#8ad9f7290150d10c4135ec3205e83569a0bce95d" + integrity sha512-tT+9glFTdA0ayyhFvpBNfcBi4wZGcr1FSiwS2aNYJrWFE0XpM4aXgq8h5bWha3mOBcQErTDHoUxRw0D/JOt69A== "@stream-io/transliterate@^1.5.5": version "1.5.5"