From 8235d45140e5ef4ffdb7f79c7c27fe5ac874f962 Mon Sep 17 00:00:00 2001
From: MartinCupela <32706194+MartinCupela@users.noreply.github.com>
Date: Mon, 16 Sep 2024 17:49:04 +0200
Subject: [PATCH] feat: add centralized dialog management (#2489)
BREAKING CHANGE:
- removes the following variables from `MessageContext`: isReactionEnabled, onReactionListClick, showDetailedReactions, reactionSelectorRef
- removes prop `messageWrapperRef` from `MessageOptions` and `MessageActions` props.
---
.../components/contexts/message-context.mdx | 24 --
.../message-components/message-ui.mdx | 24 --
.../message-components/reactions.mdx | 8 -
.../message-components/ui-components.mdx | 16 -
.../docs/React/guides/dialog-management.mdx | 107 +++++
.../docs/React/guides/theming/message-ui.mdx | 2 +-
.../React/release-guides/upgrade-to-v12.mdx | 32 ++
docusaurus/sidebars-react.json | 3 +-
package.json | 2 +-
src/components/ChatView/ChatView.tsx | 3 +-
src/components/Dialog/DialogAnchor.tsx | 116 ++++++
src/components/Dialog/DialogManager.ts | 129 +++++++
src/components/Dialog/DialogPortal.tsx | 47 +++
.../Dialog/__tests__/DialogsManager.test.js | 145 +++++++
src/components/Dialog/hooks/index.ts | 1 +
src/components/Dialog/hooks/useDialog.ts | 40 ++
src/components/Dialog/index.ts | 4 +
src/components/Message/Message.tsx | 21 +-
src/components/Message/MessageOptions.tsx | 38 +-
src/components/Message/MessageSimple.tsx | 23 +-
.../Message/__tests__/MessageOptions.test.js | 142 +++++--
.../Message/__tests__/MessageText.test.js | 2 -
.../Message/__tests__/QuotedMessage.test.js | 9 +-
.../__tests__/useReactionHandler.test.js | 197 +---------
.../Message/hooks/useReactionHandler.ts | 101 +----
src/components/Message/utils.tsx | 4 +
.../MessageActions/MessageActions.tsx | 114 ++----
.../MessageActions/MessageActionsBox.tsx | 267 ++++++-------
.../__tests__/MessageActions.test.js | 342 ++++++++--------
.../__tests__/MessageActionsBox.test.js | 162 ++++----
src/components/MessageList/MessageList.tsx | 77 ++--
.../MessageList/VirtualizedMessageList.tsx | 137 +++----
.../VirtualizedMessageListComponents.test.js | 86 ++++-
.../VirtualizedMessageList.test.js.snap | 11 +
...tualizedMessageListComponents.test.js.snap | 54 +++
src/components/Reactions/ReactionSelector.tsx | 365 +++++++++---------
.../Reactions/ReactionSelectorWithButton.tsx | 54 +++
src/components/Reactions/ReactionsList.tsx | 4 -
.../__tests__/ReactionSelector.test.js | 15 +-
src/components/Thread/Thread.tsx | 3 +-
.../Threads/ThreadList/ThreadList.tsx | 2 +-
.../Threads/ThreadList/ThreadListItemUI.tsx | 2 +-
.../ThreadList/ThreadListLoadingIndicator.tsx | 2 +-
.../ThreadListUnseenThreadsBanner.tsx | 2 +-
.../Threads/hooks/useThreadManagerState.ts | 2 +-
.../Threads/hooks/useThreadState.ts | 2 +-
src/components/Threads/index.ts | 1 -
src/components/index.ts | 1 +
src/context/DialogManagerContext.tsx | 27 ++
src/context/MessageContext.tsx | 12 +-
src/context/index.ts | 1 +
src/index.ts | 1 +
src/store/hooks/index.ts | 1 +
.../Threads => store}/hooks/useStateStore.ts | 0
src/store/index.ts | 1 +
yarn.lock | 8 +-
56 files changed, 1768 insertions(+), 1228 deletions(-)
create mode 100644 docusaurus/docs/React/guides/dialog-management.mdx
create mode 100644 src/components/Dialog/DialogAnchor.tsx
create mode 100644 src/components/Dialog/DialogManager.ts
create mode 100644 src/components/Dialog/DialogPortal.tsx
create mode 100644 src/components/Dialog/__tests__/DialogsManager.test.js
create mode 100644 src/components/Dialog/hooks/index.ts
create mode 100644 src/components/Dialog/hooks/useDialog.ts
create mode 100644 src/components/Dialog/index.ts
create mode 100644 src/components/Reactions/ReactionSelectorWithButton.tsx
create mode 100644 src/context/DialogManagerContext.tsx
create mode 100644 src/store/hooks/index.ts
rename src/{components/Threads => store}/hooks/useStateStore.ts (100%)
create mode 100644 src/store/index.ts
diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx
index 35cce32689..d18a8d4361 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 e8ad602d39..7a6bdd4ea8 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 1f3a796131..68cade8001 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 dd88e3c807..ff1a2e8504 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 0000000000..f2c5001156
--- /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 (
+ <>
+
+
+
+
+ Toggle
+
+ >
+ );
+};
+```
+
+### 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 d846e42aca..816aa6e591 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 458a7f8b2e..808b00892d 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 9b85e68089..75e7f2f47b 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 a9b3d6f058..a2152cbc96 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 da8c9f5692..6fa14b6cfe 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 0000000000..5e411ba0f4
--- /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 0000000000..503adbcf23
--- /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 0000000000..e9bb63de7f
--- /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 0000000000..f27f4d8465
--- /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 0000000000..9d08c250c7
--- /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 0000000000..d0387ab9c3
--- /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 0000000000..a2462dbcdd
--- /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 b353e1d7b3..1ed4a60cea 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 760bd1c46c..3da40fcb88 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 && (
)}
{shouldShowReactions && (
-
-
-
+
)}
);
diff --git a/src/components/Message/MessageSimple.tsx b/src/components/Message/MessageSimple.tsx
index bf8ade888e..4b6746446c 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 fb3ce9b8ad..b744bc2ad6 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 0a6fe5638c..4f561c2a9c 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 67c9cf6ae4..64f8beda8e 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 04a03f1c49..3b61291ff5 100644
--- a/src/components/Message/hooks/__tests__/useReactionHandler.test.js
+++ b/src/components/Message/hooks/__tests__/useReactionHandler.test.js
@@ -1,11 +1,7 @@
import React from 'react';
-import { act, renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react-hooks';
-import {
- reactionHandlerWarning,
- useReactionClick,
- useReactionHandler,
-} from '../useReactionHandler';
+import { reactionHandlerWarning, useReactionHandler } from '../useReactionHandler';
import { ChannelActionProvider } from '../../../../context/ChannelActionContext';
import { ChannelStateProvider } from '../../../../context/ChannelStateContext';
@@ -123,192 +119,3 @@ describe('useReactionHandler custom hook', () => {
expect(updateMessage).toHaveBeenCalledWith(message);
});
});
-
-function renderUseReactionClickHook(
- message = generateMessage(),
- reactionListRef = React.createRef(),
- messageWrapperRef = React.createRef(),
-) {
- const channel = generateChannel();
-
- const wrapper = ({ children }) => (
-
- {children}
-
- );
-
- const { rerender, result } = renderHook(
- () => useReactionClick(message, reactionListRef, messageWrapperRef),
- { wrapper },
- );
- return { rerender, result };
-}
-
-describe('useReactionClick custom hook', () => {
- beforeEach(jest.clearAllMocks);
- it('should initialize a click handler and a flag for showing detailed reactions', () => {
- const {
- result: { current },
- } = renderUseReactionClickHook();
-
- expect(typeof current.onReactionListClick).toBe('function');
- expect(current.showDetailedReactions).toBe(false);
- });
-
- it('should set show details to true on click', async () => {
- const { result } = renderUseReactionClickHook();
- expect(result.current.showDetailedReactions).toBe(false);
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- });
-
- it('should return correct value for isReactionEnabled', () => {
- const channel = generateChannel();
- const channelCapabilities = { 'send-reaction': true };
-
- const { rerender, result } = renderHook(
- () => useReactionClick(generateMessage(), React.createRef(), React.createRef()),
- {
- // eslint-disable-next-line react/display-name
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- },
- );
-
- expect(result.current.isReactionEnabled).toBe(true);
- channelCapabilities['send-reaction'] = false;
- rerender();
- expect(result.current.isReactionEnabled).toBe(false);
- channelCapabilities['send-reaction'] = true;
- rerender();
- expect(result.current.isReactionEnabled).toBe(true);
- });
-
- it('should set event listener to close reaction list on document click when list is opened', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const { result } = renderUseReactionClickHook();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- expect(document.addEventListener).toHaveBeenCalledTimes(1);
- expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
- await act(() => {
- onDocumentClick(clickMock);
- });
- expect(result.current.showDetailedReactions).toBe(false);
- addEventListenerSpy.mockRestore();
- });
-
- it('should set event listener to message wrapper reference when one is set', async () => {
- const mockMessageWrapperReference = {
- current: {
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- },
- };
- const { result } = renderUseReactionClickHook(
- generateMessage(),
- React.createRef(),
- mockMessageWrapperReference,
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(mockMessageWrapperReference.current.addEventListener).toHaveBeenCalledWith(
- 'mouseleave',
- expect.any(Function),
- );
- });
-
- it('should not close reaction list on document click when click is on the reaction list itself', async () => {
- const message = generateMessage();
- const reactionSelectorEl = document.createElement('div');
- const reactionListElement = document.createElement('div').appendChild(reactionSelectorEl);
- const clickMock = {
- target: reactionSelectorEl,
- };
- const { result } = renderUseReactionClickHook(message, {
- current: reactionListElement,
- });
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- await act(() => {
- onDocumentClick(clickMock);
- });
- expect(result.current.showDetailedReactions).toBe(true);
- addEventListenerSpy.mockRestore();
- });
-
- it('should remove close click event listeners after reaction list is closed', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const { result } = renderUseReactionClickHook();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- const removeEventListenerSpy = jest
- .spyOn(document, 'removeEventListener')
- .mockImplementationOnce(jest.fn());
- await act(() => {
- result.current.onReactionListClick();
- });
- expect(result.current.showDetailedReactions).toBe(true);
- act(() => onDocumentClick(clickMock));
- expect(result.current.showDetailedReactions).toBe(false);
- expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick);
- addEventListenerSpy.mockRestore();
- removeEventListenerSpy.mockRestore();
- });
-
- it('should remove close click event listeners if message is deleted', async () => {
- const clickMock = {
- target: document.createElement('div'),
- };
- const message = generateMessage();
- let onDocumentClick;
- const addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(
- jest.fn((_, fn) => {
- onDocumentClick = fn;
- }),
- );
- const removeEventListenerSpy = jest
- .spyOn(document, 'removeEventListener')
- .mockImplementationOnce(jest.fn());
- const { rerender, result } = renderUseReactionClickHook(message);
- expect(document.removeEventListener).not.toHaveBeenCalled();
- await act(() => {
- result.current.onReactionListClick(clickMock);
- });
- message.deleted_at = new Date();
- rerender();
- expect(document.removeEventListener).toHaveBeenCalledWith('click', onDocumentClick);
- addEventListenerSpy.mockRestore();
- removeEventListenerSpy.mockRestore();
- });
-});
diff --git a/src/components/Message/hooks/useReactionHandler.ts b/src/components/Message/hooks/useReactionHandler.ts
index 20795c3b1b..271421342f 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 6842728be9..ac52b7c73f 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 174733beed..02fe9be563 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 (
-
+
+
+
@@ -183,13 +142,13 @@ export const MessageActions = <
};
export type MessageActionsWrapperProps = {
- setActionsBoxOpen: React.Dispatch>;
customWrapperClass?: string;
inline?: boolean;
+ toggleOpen?: () => void;
};
const MessageActionsWrapper = (props: PropsWithChildren) => {
- const { children, customWrapperClass, inline, setActionsBoxOpen } = props;
+ const { children, customWrapperClass, inline, toggleOpen } = props;
const defaultWrapperClass = clsx(
'str-chat__message-simple__actions__action',
@@ -197,17 +156,10 @@ const MessageActionsWrapper = (props: PropsWithChildren {
- event.stopPropagation();
- setActionsBoxOpen((prev) => !prev);
- };
-
const wrapperProps = {
- className: wrapperClass,
+ className: customWrapperClass || defaultWrapperClass,
'data-testid': 'message-actions',
- onClick: onClickOptionsAction,
+ onClick: toggleOpen,
};
if (inline) return {children} ;
diff --git a/src/components/MessageActions/MessageActionsBox.tsx b/src/components/MessageActions/MessageActionsBox.tsx
index a19b252ad6..e7deb75ec0 100644
--- a/src/components/MessageActions/MessageActionsBox.tsx
+++ b/src/components/MessageActions/MessageActionsBox.tsx
@@ -1,5 +1,5 @@
-import React, { ComponentProps } from 'react';
import clsx from 'clsx';
+import React, { ComponentProps } from 'react';
import { MESSAGE_ACTIONS } from '../Message/utils';
@@ -32,139 +32,140 @@ export type MessageActionsBoxProps<
open: boolean;
} & ComponentProps<'div'>;
-const UnMemoizedMessageActionsBox = React.forwardRef(
- (
- props: MessageActionsBoxProps,
- ref: React.ForwardedRef,
- ) => {
- const {
- getMessageActions,
- handleDelete,
- handleEdit,
- handleFlag,
- handleMarkUnread,
- handleMute,
- handlePin,
- isUserMuted,
- mine,
- open = false,
- ...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', {
- '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 && (
-
- {t('Reply')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
-
- {!message.pinned ? t('Pin') : t('Unpin')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
-
- {t('Mark as unread')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
-
- {t('Flag')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
-
- {isUserMuted() ? t('Unmute') : t('Mute')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
-
- {t('Edit Message')}
-
- )}
- {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
-
- {t('Delete')}
-
- )}
-
+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 && (
+
+ {t('Reply')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.pin) > -1 && !message.parent_id && (
+
+ {!message.pinned ? t('Pin') : t('Unpin')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.markUnread) > -1 && !threadList && !!message.id && (
+
+ {t('Mark as unread')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.flag) > -1 && (
+
+ {t('Flag')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.mute) > -1 && (
+
+ {isUserMuted() ? t('Unmute') : t('Mute')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.edit) > -1 && (
+
+ {t('Edit Message')}
+
+ )}
+ {messageActions.indexOf(MESSAGE_ACTIONS.delete) > -1 && (
+
+ {t('Delete')}
+
+ )}
- );
- },
-);
+
+ );
+};
/**
* 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 9c7afa2d36..1e03d80b35 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 2f786facf2..6a62a5383d 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 a2a14eb977..9a244c9016 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}
- >
-
-
-
-
-
+
+ {!threadList && showUnreadMessagesNotification && (
+
)}
-
+
+ {showEmptyStateIndicator ? (
+
+ ) : (
+
+ {props.loadingMore && }
+
+ }
+ loadNextPage={loadMoreNewer}
+ loadPreviousPage={loadMore}
+ threshold={loadMoreScrollThreshold}
+ {...restInternalInfiniteScrollProps}
+ >
+
+
+
+
+
+ )}
+
+
- {!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 19b10d30c4..7c708d2723 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 4c5875ed0a..ceeea4c4b0 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 (
-
- handleReaction(reactionType, event)}
- >
- {!!count && detailedView && (
- showTooltip(e, reactionType)}
- onMouseLeave={hideTooltip}
- >
- {latestUser ? (
-
- ) : (
-
- )}
-
- )}
-
-
+ },
+ )}
+ data-testid='reaction-selector'
+ ref={rootRef}
+ >
+ {!!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 (
+
+ {
+ handleReaction(reactionType, event);
+ if (closeReactionSelectorOnClick) {
+ dialog.close();
+ }
+ }}
+ >
+ {!!count && detailedView && (
+ showTooltip(e, reactionType)}
+ onMouseLeave={hideTooltip}
+ >
+ {latestUser ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ {Boolean(count) && detailedView && (
+
+ {count || ''}
- {Boolean(count) && detailedView && (
-
- {count || ''}
-
- )}
-
-
- );
- })}
-
-
- );
- },
-);
+ )}
+
+
+ );
+ })}
+
+
+ );
+};
/**
* Component that allows a user to select a reaction.
diff --git a/src/components/Reactions/ReactionSelectorWithButton.tsx b/src/components/Reactions/ReactionSelectorWithButton.tsx
new file mode 100644
index 0000000000..86513ee65f
--- /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 (
+ <>
+
+
+
+ dialog?.toggle()}
+ ref={buttonRef}
+ >
+
+
+ >
+ );
+};
diff --git a/src/components/Reactions/ReactionsList.tsx b/src/components/Reactions/ReactionsList.tsx
index d5974854a8..c03025e445 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 500d9d9e4c..3b668ea76a 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 6ede0894a2..a02b1c7370 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 fdec3a5200..e397bd427e 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 f64ffdc86e..f1cef2dd0b 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 da9da4ea42..e778b30359 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 5d2178002a..c7409f5ae8 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 1ee2e85b29..18ac8c7fd7 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 be02838efd..f6d8eb7a89 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 7347139bd7..454098f8cc 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 c6bef9f87d..872fd5e083 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 0000000000..b1f14126d9
--- /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 6af8d11d85..3cd6bbfdf3 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 8a1be8302e..15e3f422be 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 e5eb9f3219..b86ce062dc 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 0000000000..5a67cce005
--- /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 0000000000..4cc90d02bd
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
diff --git a/yarn.lock b/yarn.lock
index 229ec0df53..92b9510f86 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"