;
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…
-
`;
@@ -115,12 +97,6 @@ exports[`VirtualizedMessageComponents Header should not render custom head in He
-
`;
@@ -170,12 +146,6 @@ exports[`VirtualizedMessageComponents Header should render LoadingIndicator in H
-
`;
@@ -185,12 +155,6 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
class="XXX"
data-item-index="10000000"
/>
-
`;
@@ -200,12 +164,6 @@ exports[`VirtualizedMessageComponents Item should render wrapper with custom cla
class="XXX"
data-item-index="10000000"
/>
-
`;
@@ -215,12 +173,6 @@ 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"
/>
-
`;
@@ -230,11 +182,5 @@ 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/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx
new file mode 100644
index 0000000000..4c511f73cd
--- /dev/null
+++ b/src/components/Modal/GlobalModal.tsx
@@ -0,0 +1,87 @@
+import clsx from 'clsx';
+import type { PropsWithChildren } from 'react';
+import React, { useEffect, useRef } from 'react';
+import { FocusScope } from '@react-aria/focus';
+
+import { CloseIconRound } from './icons';
+
+import { useTranslationContext } from '../../context';
+import {
+ DialogPortalEntry,
+ modalDialogId,
+ useModalDialog,
+ useModalDialogIsOpen,
+} from '../Dialog';
+import type { ModalProps } from './Modal';
+
+export const GlobalModal = ({
+ children,
+ className,
+ onClose,
+ open,
+}: PropsWithChildren) => {
+ const { t } = useTranslationContext('Modal');
+
+ const dialog = useModalDialog();
+ const isOpen = useModalDialogIsOpen();
+ const innerRef = useRef(null);
+ const closeRef = useRef(null);
+
+ const handleClick = (event: React.MouseEvent) => {
+ if (innerRef.current?.contains(event.target as HTMLButtonElement | HTMLDivElement))
+ return;
+ onClose?.(event);
+ dialog.close();
+ };
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose?.(event as unknown as React.KeyboardEvent);
+ dialog.close();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [dialog, onClose, isOpen]);
+
+ useEffect(() => {
+ if (open && !dialog.isOpen) {
+ dialog.open();
+ }
+ }, [dialog, open]);
+
+ if (!open || !isOpen) return null;
+
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+};
diff --git a/src/components/Modal/__tests__/GlobalModal.test.js b/src/components/Modal/__tests__/GlobalModal.test.js
new file mode 100644
index 0000000000..4922e45788
--- /dev/null
+++ b/src/components/Modal/__tests__/GlobalModal.test.js
@@ -0,0 +1,108 @@
+import React from 'react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+
+import '@testing-library/jest-dom';
+
+import { GlobalModal } from '../GlobalModal';
+import { ModalDialogManagerProvider } from '../../../context';
+
+const renderComponent = ({ props } = {}) =>
+ render(
+
+
+ ,
+ );
+
+describe('GlobalModal', () => {
+ const textContent = 'some text';
+ afterEach(cleanup);
+
+ it('should be closed (null) if the `open` prop is set to false', () => {
+ const { container } = renderComponent({ props: { open: false } });
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should be open if the `open` prop is set to true', () => {
+ const { container } = renderComponent({ props: { open: true } });
+ const dialogOverlay = container.firstChild;
+
+ expect(dialogOverlay.firstChild).toHaveClass('str-chat__modal--open');
+ });
+
+ it('should render what is passed as props.children when opened', () => {
+ const { queryByText } = renderComponent({
+ props: { children: textContent, open: true },
+ });
+
+ expect(queryByText(textContent)).toBeInTheDocument();
+ });
+
+ it('should call the onClose prop function if the escape key is pressed', () => {
+ const onClose = jest.fn();
+ renderComponent({
+ props: { onClose, open: true },
+ });
+
+ fireEvent(
+ document,
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ }),
+ );
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should remove the escape keydown event handler on unmount', () => {
+ const onClose = jest.fn();
+ const { unmount } = renderComponent({
+ props: { onClose, open: true },
+ });
+
+ unmount();
+ fireEvent(
+ document,
+ new KeyboardEvent('keydown', {
+ key: 'Escape',
+ }),
+ );
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('should not call onClose if the inside of the modal was clicked', () => {
+ const onClose = jest.fn();
+ renderComponent({
+ props: { children: textContent, onClose, open: true },
+ });
+ const textContainer = screen.queryByText(textContent);
+
+ fireEvent.click(textContainer);
+
+ expect(onClose).not.toHaveBeenCalled();
+ });
+
+ it('should call onClose if the close button is clicked', () => {
+ const onClose = jest.fn();
+ renderComponent({
+ props: { children: textContent, onClose, open: true },
+ });
+ const closeButton = screen.getByTitle('Close');
+ fireEvent.click(closeButton);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onClose if the modal overlay is clicked', () => {
+ const onClose = jest.fn();
+ const { container, debug } = renderComponent({
+ props: { children: textContent, onClose, open: true },
+ });
+ console.log(debug(container));
+ const dialogOverlay = container.querySelector('.str-chat__modal');
+
+ fireEvent.click(dialogOverlay);
+
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+});
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..3dbcf53489 100644
--- a/src/context/DialogManagerContext.tsx
+++ b/src/context/DialogManagerContext.tsx
@@ -1,8 +1,37 @@
-import React, { useContext, useState } from 'react';
-import type { PropsWithChildren } from 'react';
+import React, {
+ type PropsWithChildren,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import { StateStore } from 'stream-chat';
import { DialogManager } from '../components/Dialog/DialogManager';
import { DialogPortalDestination } from '../components/Dialog/DialogPortal';
+import type { PropsWithChildrenOnly } from '../types/types';
+
+type DialogManagerId = string;
+
+type DialogManagersState = Record;
+const dialogManagersRegistry: StateStore = new StateStore({});
+
+const getDialogManager = (id: string): DialogManager | undefined =>
+ dialogManagersRegistry.getLatestValue()[id];
+
+const getOrCreateDialogManager = (id: string) => {
+ let manager = getDialogManager(id);
+ if (!manager) {
+ manager = new DialogManager({ id });
+ dialogManagersRegistry.partialNext({ [id]: manager });
+ }
+ return manager;
+};
+
+const removeDialogManager = (id: string) => {
+ if (!getDialogManager(id)) return;
+ dialogManagersRegistry.partialNext({ [id]: undefined });
+};
type DialogManagerProviderContextValue = {
dialogManager: DialogManager;
@@ -12,11 +41,32 @@ 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, setDialogManager] = useState(() => {
+ if (id) return getDialogManager(id) ?? null;
+ return new DialogManager(); // will not be included in the registry
+ });
+
+ useEffect(() => {
+ if (!id) return;
+ setDialogManager(getOrCreateDialogManager(id));
+ return () => {
+ removeDialogManager(id);
+ setDialogManager(null);
+ };
+ }, [id]);
+
+ // temporarily do not render until a new dialog manager is created
+ if (!dialogManager) return null;
return (
@@ -26,7 +76,115 @@ export const DialogManagerProvider = ({
);
};
-export const useDialogManager = () => {
- const value = useContext(DialogManagerProviderContext);
- return value as DialogManagerProviderContextValue;
+export type UseDialogManagerParams = {
+ dialogId?: string;
+ dialogManagerId?: string;
+};
+
+const getManagerFromStore = ({
+ dialogId,
+ dialogManagerId,
+ newState,
+ previousState,
+}: UseDialogManagerParams & {
+ newState: DialogManagersState;
+ previousState: DialogManagersState | undefined;
+}) => {
+ let managerInNewState: DialogManager | undefined;
+ let managerInPrevState: DialogManager | undefined;
+ if (dialogManagerId) {
+ if (!dialogId) {
+ managerInNewState = newState[dialogManagerId];
+ managerInPrevState = previousState?.[dialogManagerId];
+ } else {
+ if (newState[dialogManagerId]?.get(dialogId)) {
+ managerInNewState = newState[dialogManagerId];
+ }
+ if (previousState?.[dialogManagerId]?.get(dialogId)) {
+ managerInPrevState = previousState[dialogManagerId];
+ }
+ }
+ } else if (dialogId) {
+ managerInNewState = Object.values(newState).find(
+ (dialogMng) => dialogId && dialogMng?.get(dialogId),
+ );
+ managerInPrevState =
+ previousState &&
+ Object.values(previousState).find(
+ (dialogMng) => dialogId && dialogMng?.get(dialogId),
+ );
+ }
+
+ return { managerInNewState, managerInPrevState };
};
+
+/**
+ * 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 [dialogManagerContext, setDialogManagerContext] = useState<
+ DialogManagerProviderContextValue | undefined
+ >(() => {
+ const { managerInNewState } = getManagerFromStore({
+ dialogId,
+ dialogManagerId,
+ newState: dialogManagersRegistry.getLatestValue(),
+ previousState: undefined,
+ });
+ return managerInNewState
+ ? { dialogManager: managerInNewState }
+ : nearestDialogManagerContext;
+ });
+
+ useEffect(() => {
+ if (!dialogId && !dialogManagerId) return;
+ const unsubscribe = dialogManagersRegistry.subscribeWithSelector(
+ (state) => state,
+ (newState, previousState) => {
+ const { managerInNewState, managerInPrevState } = getManagerFromStore({
+ dialogId,
+ dialogManagerId,
+ newState,
+ previousState,
+ });
+
+ if (!managerInPrevState || managerInNewState?.id !== managerInPrevState.id) {
+ setDialogManagerContext((prevState) => {
+ if (prevState?.dialogManager.id === managerInNewState?.id) return prevState;
+ // fixme: need to handle the possibility that the dialogManager is undefined
+ return {
+ dialogManager:
+ managerInNewState || nearestDialogManagerContext?.dialogManager,
+ } as DialogManagerProviderContextValue;
+ });
+ }
+ },
+ );
+ return () => {
+ unsubscribe();
+ };
+ }, [dialogId, dialogManagerId, nearestDialogManagerContext?.dialogManager]);
+
+ if (!dialogManagerContext?.dialogManager) {
+ console.warn(
+ `Dialog manager (manager id: ${dialogManagerId}, dialog id: ${dialogId}) is not available`,
+ );
+ }
+
+ return dialogManagerContext as DialogManagerProviderContextValue;
+};
+
+export const modalDialogManagerId = 'modal-dialog-manager' as const;
+
+export const ModalDialogManagerProvider = ({ children }: PropsWithChildrenOnly) => (
+ {children}
+);
+
+export const useModalDialogManager = () =>
+ useMemo(() => getDialogManager(modalDialogManagerId), []);
diff --git a/yarn.lock b/yarn.lock
index 95c5c4c38b..0c1ab99359 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2570,10 +2570,10 @@
resolved "https://registry.yarnpkg.com/@stream-io/escape-string-regexp/-/escape-string-regexp-5.0.1.tgz#362505c92799fea6afe4e369993fbbda8690cc37"
integrity sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ==
-"@stream-io/stream-chat-css@^5.11.2":
- version "5.11.2"
- resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.11.2.tgz#cd7994c58cfb676ed83967e2f1afbcef49ad94c6"
- integrity sha512-8uJppnqoplYryRxPI/0oQrFjNlF1RvTueC8o7pJPnjcPCrEKmwIv9UqwTX19ghK+5445dgBXEyX939vzn4VSiA==
+"@stream-io/stream-chat-css@^5.12.0":
+ version "5.12.0"
+ resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.12.0.tgz#4b503c50b9361feee792ca633ee2e691d68c647c"
+ integrity sha512-amXWOOhecgqLhAr3l0lCAP29FGZuFhRIADtOPvzIpXwxjVhs4WQjd5uZHhONr+yeLZfkO3ujBXW6nP8SQTr7Nw==
"@stream-io/transliterate@^1.5.5":
version "1.5.5"