+
+ );
+};
diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts
index cb89ee1788..f1087f3863 100644
--- a/src/components/Modal/index.ts
+++ b/src/components/Modal/index.ts
@@ -1 +1,2 @@
+export * from './GlobalModal';
export * from './Modal';
diff --git a/src/components/Poll/PollActions/PollAction.tsx b/src/components/Poll/PollActions/PollAction.tsx
index add7fd3124..bd23f46351 100644
--- a/src/components/Poll/PollActions/PollAction.tsx
+++ b/src/components/Poll/PollActions/PollAction.tsx
@@ -1,6 +1,7 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
-import { Modal } from '../../Modal';
+import { Modal as DefaultModal } from '../../Modal';
+import { useComponentContext } from '../../../context';
export type PollActionProps = {
buttonText: string;
@@ -17,13 +18,16 @@ export const PollAction = ({
modalClassName,
modalIsOpen,
openModal,
-}: PropsWithChildren) => (
- <>
-
-
- {children}
-
- >
-);
+}: PropsWithChildren) => {
+ const { Modal = DefaultModal } = useComponentContext();
+ return (
+ <>
+
+
+ {children}
+
+ >
+ );
+};
diff --git a/src/components/Poll/PollActions/PollActions.tsx b/src/components/Poll/PollActions/PollActions.tsx
index 2615199fd2..57b91beb15 100644
--- a/src/components/Poll/PollActions/PollActions.tsx
+++ b/src/components/Poll/PollActions/PollActions.tsx
@@ -1,3 +1,4 @@
+import clsx from 'clsx';
import React, { useCallback, useState } from 'react';
import { PollAction } from './PollAction';
import type { AddCommentFormProps } from './AddCommentForm';
@@ -24,6 +25,8 @@ import { useStateStore } from '../../../store';
import type { PollAnswer, PollOption, PollState } from 'stream-chat';
+const COMMON_MODAL_CLASS = 'str-chat__poll-action-modal' as const;
+
type ModalName =
| 'suggest-option'
| 'add-comment'
@@ -95,6 +98,7 @@ export const PollActions = ({
count: options.length,
})}
closeModal={closeModal}
+ modalClassName={COMMON_MODAL_CLASS}
modalIsOpen={modalOpen === 'view-all-options'}
openModal={() => setModalOpen('view-all-options')}
>
@@ -108,7 +112,10 @@ export const PollActions = ({
setModalOpen('suggest-option')}
>
@@ -120,7 +127,7 @@ export const PollActions = ({
setModalOpen('add-comment')}
>
@@ -132,7 +139,7 @@ export const PollActions = ({
setModalOpen('view-comments')}
>
@@ -146,7 +153,7 @@ export const PollActions = ({
setModalOpen('view-results')}
>
@@ -157,7 +164,7 @@ export const PollActions = ({
setModalOpen('end-vote')}
>
diff --git a/src/components/Reactions/ReactionsListModal.tsx b/src/components/Reactions/ReactionsListModal.tsx
index 33e63fa1b7..4d2da8a54b 100644
--- a/src/components/Reactions/ReactionsListModal.tsx
+++ b/src/components/Reactions/ReactionsListModal.tsx
@@ -3,14 +3,14 @@ import clsx from 'clsx';
import type { ReactionDetailsComparator, ReactionSummary, ReactionType } from './types';
-import type { ModalProps } from '../Modal';
-import { Modal } from '../Modal';
+import { Modal as DefaultModal } from '../Modal';
import { useFetchReactions } from './hooks/useFetchReactions';
import { LoadingIndicator } from '../Loading';
import { Avatar } from '../Avatar';
-import type { MessageContextValue } from '../../context';
-import { useMessageContext } from '../../context';
+import { useComponentContext, useMessageContext } from '../../context';
import type { ReactionSort } from 'stream-chat';
+import type { ModalProps } from '../Modal';
+import type { MessageContextValue } from '../../context';
export type ReactionsListModalProps = ModalProps &
Partial> & {
@@ -33,6 +33,7 @@ export function ReactionsListModal({
sortReactionDetails: propSortReactionDetails,
...modalProps
}: ReactionsListModalProps) {
+ const { Modal = DefaultModal } = useComponentContext();
const selectedReaction = reactions.find(
({ reactionType }) => reactionType === selectedReactionType,
);
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index d5305cad55..af8657f20d 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -28,6 +28,7 @@ import type {
MessageTimestampProps,
MessageUIComponentProps,
ModalGalleryProps,
+ ModalProps,
PinIndicatorProps,
PollCreationDialogProps,
PollOptionSelectorProps,
@@ -147,6 +148,8 @@ export type ComponentContextValue = {
MessageSystem?: React.ComponentType;
/** Custom UI component to display a timestamp on a message, defaults to and accepts same props as: [MessageTimestamp](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageTimestamp.tsx) */
MessageTimestamp?: React.ComponentType;
+ /** Custom UI component for viewing content in a modal, defaults to and accepts the same props as [Modal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Modal/Modal.tsx) */
+ Modal?: React.ComponentType;
/** Custom UI component for viewing message's image attachments, defaults to and accepts the same props as [ModalGallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/ModalGallery.tsx) */
ModalGallery?: React.ComponentType;
/** Custom UI component to override default pinned message indicator, defaults to and accepts same props as: [PinIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/icons.tsx) */
diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx
index 1e9722f1b1..00f0e71694 100644
--- a/src/context/DialogManagerContext.tsx
+++ b/src/context/DialogManagerContext.tsx
@@ -1,8 +1,29 @@
-import React, { useContext, useState } from 'react';
import type { PropsWithChildren } from 'react';
+import React, { useContext, useEffect, useMemo } from 'react';
import { DialogManager } from '../components/Dialog/DialogManager';
import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
+import type { PropsWithChildrenOnly } from '../types/types';
+import { StateStore } from 'stream-chat';
+
+type DialogManagerId = string;
+
+const dialogManagersStore = new StateStore>({});
+
+const getDialogManager = (id: string): DialogManager | undefined =>
+ dialogManagersStore.getLatestValue()[id];
+
+const addDialogManager = (dialogManager: DialogManager) => {
+ if (getDialogManager(dialogManager.id)) return;
+ dialogManagersStore.partialNext({ [dialogManager.id]: dialogManager });
+};
+
+const removeDialogManager = (id: string) => {
+ const { ...dialogManagers } = dialogManagersStore.getLatestValue();
+ if (!dialogManagers[id]) return;
+ delete dialogManagers[id];
+ dialogManagersStore.next(dialogManagers);
+};
type DialogManagerProviderContextValue = {
dialogManager: DialogManager;
@@ -12,11 +33,27 @@ const DialogManagerProviderContext = React.createContext<
DialogManagerProviderContextValue | undefined
>(undefined);
+/**
+ * Marks the portal location
+ * @param children
+ * @param id
+ * @constructor
+ */
export const DialogManagerProvider = ({
children,
id,
}: PropsWithChildren<{ id?: string }>) => {
- const [dialogManager] = useState(() => new DialogManager({ id }));
+ const dialogManager = useMemo(
+ () => (id && getDialogManager(id)) || new DialogManager({ id }),
+ [id],
+ );
+
+ useEffect(() => {
+ addDialogManager(dialogManager);
+ return () => {
+ removeDialogManager(dialogManager.id);
+ };
+ }, [dialogManager]);
return (
@@ -26,7 +63,54 @@ export const DialogManagerProvider = ({
);
};
-export const useDialogManager = () => {
- const value = useContext(DialogManagerProviderContext);
- return value as DialogManagerProviderContextValue;
+export type UseDialogManagerParams = {
+ dialogId?: string;
+ dialogManagerId?: string;
+};
+
+/**
+ * Retrieves the nearest dialog manager or searches for the dialog manager by dialog manager id or dialog id.
+ * Dialog id will take precedence over dialog manager id if both are provided and dialog manager is found by dialog id.
+ */
+export const useDialogManager = ({
+ dialogId,
+ dialogManagerId,
+}: UseDialogManagerParams = {}) => {
+ const nearestDialogManagerContext = useContext(DialogManagerProviderContext);
+
+ const foundDialogManagerContext = useMemo(() => {
+ const context: { dialogManager?: DialogManager } = { dialogManager: undefined };
+
+ if (!dialogId && !dialogManagerId) return context;
+
+ const dialogManagers = dialogManagersStore.getLatestValue();
+
+ if (
+ (dialogManagerId && !dialogId) ||
+ (dialogManagerId && dialogId && dialogManagers[dialogManagerId].get(dialogId))
+ ) {
+ context.dialogManager = dialogManagers[dialogManagerId];
+ }
+ if (dialogId) {
+ context.dialogManager = Object.values(dialogManagers).find(
+ (dialogMng) => dialogId && dialogMng.get(dialogId),
+ );
+ }
+ return context;
+ }, [dialogId, dialogManagerId]);
+
+ return (
+ foundDialogManagerContext.dialogManager
+ ? foundDialogManagerContext
+ : nearestDialogManagerContext
+ ) as DialogManagerProviderContextValue;
};
+
+export const modalDialogManagerId = 'modal-dialog-manager' as const;
+
+export const ModalDialogManagerProvider = ({ children }: PropsWithChildrenOnly) => (
+ {children}
+);
+
+export const useModalDialogManager = () =>
+ useMemo(() => getDialogManager(modalDialogManagerId), []);
From ec98d7037ddff4374c0ae9a1cc301d680465892f Mon Sep 17 00:00:00 2001
From: martincupela
Date: Fri, 1 Aug 2025 15:01:54 +0200
Subject: [PATCH 02/10] refactor: prevent using StateStore for
dialogManagersStore
---
src/context/DialogManagerContext.tsx | 21 ++++++++-------------
1 file changed, 8 insertions(+), 13 deletions(-)
diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx
index 00f0e71694..b9acf6dd36 100644
--- a/src/context/DialogManagerContext.tsx
+++ b/src/context/DialogManagerContext.tsx
@@ -4,25 +4,22 @@ import React, { useContext, useEffect, useMemo } from 'react';
import { DialogManager } from '../components/Dialog/DialogManager';
import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
import type { PropsWithChildrenOnly } from '../types/types';
-import { StateStore } from 'stream-chat';
type DialogManagerId = string;
-const dialogManagersStore = new StateStore>({});
+const dialogManagersStore: Record = {};
const getDialogManager = (id: string): DialogManager | undefined =>
- dialogManagersStore.getLatestValue()[id];
+ dialogManagersStore[id];
const addDialogManager = (dialogManager: DialogManager) => {
if (getDialogManager(dialogManager.id)) return;
- dialogManagersStore.partialNext({ [dialogManager.id]: dialogManager });
+ dialogManagersStore[dialogManager.id] = dialogManager;
};
const removeDialogManager = (id: string) => {
- const { ...dialogManagers } = dialogManagersStore.getLatestValue();
- if (!dialogManagers[id]) return;
- delete dialogManagers[id];
- dialogManagersStore.next(dialogManagers);
+ if (!dialogManagersStore[id]) return;
+ delete dialogManagersStore[id];
};
type DialogManagerProviderContextValue = {
@@ -83,16 +80,14 @@ export const useDialogManager = ({
if (!dialogId && !dialogManagerId) return context;
- const dialogManagers = dialogManagersStore.getLatestValue();
-
if (
(dialogManagerId && !dialogId) ||
- (dialogManagerId && dialogId && dialogManagers[dialogManagerId].get(dialogId))
+ (dialogManagerId && dialogId && dialogManagersStore[dialogManagerId].get(dialogId))
) {
- context.dialogManager = dialogManagers[dialogManagerId];
+ context.dialogManager = dialogManagersStore[dialogManagerId];
}
if (dialogId) {
- context.dialogManager = Object.values(dialogManagers).find(
+ context.dialogManager = Object.values(dialogManagersStore).find(
(dialogMng) => dialogId && dialogMng.get(dialogId),
);
}
From a929be48a38628215a417690e4522386960edabf Mon Sep 17 00:00:00 2001
From: martincupela
Date: Fri, 1 Aug 2025 17:22:19 +0200
Subject: [PATCH 03/10] refactor: prevent rendering elements in
DialogPortalDestination when no dialogs are open
---
src/components/Dialog/DialogPortal.tsx | 2 +
.../__tests__/MessageActions.test.js | 33 ++------
.../VirtualizedMessageListComponents.test.js | 79 ++-----------------
.../VirtualizedMessageList.test.js.snap | 6 --
...tualizedMessageListComponents.test.js.snap | 54 -------------
5 files changed, 14 insertions(+), 160 deletions(-)
diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx
index 56a2954bb4..5e19787962 100644
--- a/src/components/Dialog/DialogPortal.tsx
+++ b/src/components/Dialog/DialogPortal.tsx
@@ -8,6 +8,8 @@ export const DialogPortalDestination = () => {
const { dialogManager } = useDialogManager();
const openedDialogCount = useOpenedDialogCount();
+ if (!openedDialogCount) return null;
+
return (
component', () => {
-
`);
});
@@ -129,8 +123,6 @@ describe(' component', () => {
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 act(async () => {
await toggleOpenMessageActions();
});
@@ -138,12 +130,12 @@ describe(' component', () => {
expect.objectContaining({ open: true }),
undefined,
);
+ const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
expect(dialogOverlay.children.length).toBeGreaterThan(0);
});
it('should close message actions box on icon click if already opened', async () => {
renderMessageActions();
- const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
expect(MessageActionsBoxMock).not.toHaveBeenCalled();
await toggleOpenMessageActions();
expect(MessageActionsBoxMock).toHaveBeenLastCalledWith(
@@ -151,7 +143,8 @@ describe(' component', () => {
undefined,
);
await toggleOpenMessageActions();
- expect(dialogOverlay.children).toHaveLength(0);
+ const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
+ expect(dialogOverlay).not.toBeInTheDocument();
});
it('should close message actions box when user clicks overlay if it is already opened', async () => {
@@ -161,23 +154,23 @@ describe(' component', () => {
expect.objectContaining({ open: true }),
undefined,
);
- const dialogOverlay = screen.getByTestId(dialogOverlayTestId);
+ const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
await act(async () => {
await fireEvent.click(dialogOverlay);
});
expect(MessageActionsBoxMock).toHaveBeenCalledTimes(1);
- expect(dialogOverlay.children).toHaveLength(0);
+ expect(dialogOverlay).not.toBeInTheDocument();
});
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);
+ const dialogOverlay = screen.queryByTestId(dialogOverlayTestId);
+ expect(dialogOverlay).not.toBeInTheDocument();
});
it('should render the message actions box correctly', async () => {
@@ -252,12 +245,6 @@ describe(' component', () => {
-
`);
});
@@ -293,12 +280,6 @@ describe(' component', () => {
-
`);
});
diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
index 7ee6b0e86b..fd879ab2fe 100644
--- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
+++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js
@@ -90,16 +90,7 @@ 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).toBeEmptyDOMElement();
});
it('should render LoadingIndicator in Header when loading more messages', () => {
@@ -124,12 +115,6 @@ describe('VirtualizedMessageComponents', () => {
Custom Loading Indicator
-
`);
});
@@ -137,16 +122,7 @@ 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).toBeEmptyDOMElement();
});
// FIXME: this is a crazy pattern of having to set LoadingIndicator to null so that additionalVirtuosoProps.head can be rendered.
@@ -171,12 +147,6 @@ describe('VirtualizedMessageComponents', () => {
- `);
+ expect(container).toBeEmptyDOMElement();
});
});
diff --git a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
index e20bc91d00..1a4d1ff5f4 100644
--- a/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
+++ b/src/components/MessageList/__tests__/__snapshots__/VirtualizedMessageList.test.js.snap
@@ -53,12 +53,6 @@ exports[`VirtualizedMessageList should render the list without any message 1`] =
-
Custom EmptyStateIndicator
-
`;
@@ -23,12 +17,6 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render custom Empt
>
Custom EmptyStateIndicator
-
`;
@@ -57,12 +45,6 @@ exports[`VirtualizedMessageComponents EmptyPlaceholder should render for main me
No chats here yet…