diff --git a/package.json b/package.json index b5942a3909..ff71987abc 100644 --- a/package.json +++ b/package.json @@ -183,7 +183,7 @@ "@playwright/test": "^1.42.1", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", - "@stream-io/stream-chat-css": "^5.11.2", + "@stream-io/stream-chat-css": "^5.12.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", diff --git a/src/components/Attachment/__tests__/Geolocation.test.js b/src/components/Attachment/__tests__/Geolocation.test.js index 2327e91f25..35f7ae01c9 100644 --- a/src/components/Attachment/__tests__/Geolocation.test.js +++ b/src/components/Attachment/__tests__/Geolocation.test.js @@ -106,7 +106,10 @@ describe.each([ }); it('renders own live location', async () => { - const location = generateLiveLocationResponse({ user_id: ownUser.id }); + const location = generateLiveLocationResponse({ + end_at: new Date(Date.now() + 10000).toISOString(), + user_id: ownUser.id, + }); await renderComponent({ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, }); @@ -129,7 +132,10 @@ describe.each([ } }); it("other user's live location", async () => { - const location = generateLiveLocationResponse({ user_id: otherUser.id }); + const location = generateLiveLocationResponse({ + end_at: new Date(Date.now() + 10000).toISOString(), + user_id: otherUser.id, + }); await renderComponent({ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location }, }); diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 8746dda216..d40905a7f2 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -128,6 +128,7 @@ type ChannelPropsForwardedToComponentContext = Pick< | 'MessageStatus' | 'MessageSystem' | 'MessageTimestamp' + | 'Modal' | 'ModalGallery' | 'PinIndicator' | 'PollActions' @@ -1221,6 +1222,7 @@ const ChannelInner = ( MessageStatus: props.MessageStatus, MessageSystem: props.MessageSystem, MessageTimestamp: props.MessageTimestamp, + Modal: props.Modal, ModalGallery: props.ModalGallery, PinIndicator: props.PinIndicator, PollActions: props.PollActions, @@ -1288,6 +1290,7 @@ const ChannelInner = ( props.MessageStatus, props.MessageSystem, props.MessageTimestamp, + props.Modal, props.ModalGallery, props.PinIndicator, props.PollActions, diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 4122929eb1..3f42b32d8e 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -14,7 +14,7 @@ import { useChannelsQueryState } from './hooks/useChannelsQueryState'; import { ChatProvider } from '../../context/ChatContext'; import { TranslationProvider } from '../../context/TranslationContext'; import type { CustomClasses } from '../../context/ChatContext'; -import type { MessageContextValue } from '../../context'; +import { type MessageContextValue, ModalDialogManagerProvider } from '../../context'; import type { SupportedTranslations } from '../../i18n/types'; import type { Streami18n } from '../../i18n/Streami18n'; @@ -110,7 +110,9 @@ export const Chat = (props: PropsWithChildren) => { return ( - {children} + + {children} + ); }; diff --git a/src/components/Dialog/DialogManager.ts b/src/components/Dialog/DialogManager.ts index bc4ff06ce8..a6c345e511 100644 --- a/src/components/Dialog/DialogManager.ts +++ b/src/components/Dialog/DialogManager.ts @@ -1,9 +1,10 @@ import { nanoid } from 'nanoid'; import { StateStore } from 'stream-chat'; -export type GetOrCreateDialogParams = { +export type GetDialogParams = { id: DialogId; }; +export type GetOrCreateDialogParams = GetDialogParams; type DialogId = string; @@ -57,6 +58,10 @@ export class DialogManager { ); } + get(id: DialogId) { + return this.state.getLatestValue().dialogsById[id]; + } + getOrCreate({ id }: GetOrCreateDialogParams) { let dialog = this.state.getLatestValue().dialogsById[id]; if (!dialog) { diff --git a/src/components/Dialog/DialogPortal.tsx b/src/components/Dialog/DialogPortal.tsx index f18778f387..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 (
) => { - const { dialogManager } = useDialogManager(); - const dialogIsOpen = useDialogIsOpen(dialogId); + const { dialogManager } = useDialogManager({ dialogId }); + const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager.id); const getPortalDestination = useCallback( () => document.querySelector(`div[data-str-chat__portal-id="${dialogManager.id}"]`), diff --git a/src/components/Dialog/__tests__/DialogManagerContext.test.js b/src/components/Dialog/__tests__/DialogManagerContext.test.js new file mode 100644 index 0000000000..cd87b32fc7 --- /dev/null +++ b/src/components/Dialog/__tests__/DialogManagerContext.test.js @@ -0,0 +1,262 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + DialogManagerProvider, + useDialogManager, +} from '../../../context/DialogManagerContext'; + +import '@testing-library/jest-dom'; +import { useDialogIsOpen, useOpenedDialogCount } from '../hooks'; + +const TEST_IDS = { + CLOSE_DIALOG: 'close-dialog', + DIALOG_COUNT: 'dialog-count', + DIALOG_OPEN: 'dialog-open', + MANAGER_ID_DISPLAY: 'manager-id-display', + OPEN_DIALOG: 'open-dialog', + TEST_COMPONENT: 'test-component', +}; + +const TEST_MANAGER_ID = 'test-manager'; +const SHARED_MANAGER_ID = 'shared-manager'; +const MANAGER_1_ID = 'manager-1'; +const MANAGER_2_ID = 'manager-2'; + +const TestComponent = ({ dialogId, dialogManagerId, testId }) => { + const { dialogManager } = useDialogManager({ dialogId, dialogManagerId }); + const openDialogCount = useOpenedDialogCount({ dialogManagerId }); + const isOpen = useDialogIsOpen(dialogId, dialogManagerId); + return ( +
+ {dialogManager?.id} + {openDialogCount} + {isOpen ? 'true' : 'false'} +
+ ); +}; + +const DialogTestComponent = ({ dialogId, managerId }) => { + const { dialogManager } = useDialogManager({ dialogManagerId: managerId }); + + const handleOpenDialog = () => { + if (dialogManager) { + dialogManager.open({ id: dialogId }); + } + }; + + const handleCloseDialog = () => { + if (dialogManager) { + dialogManager.close(dialogId); + } + }; + + return ( +
+ + +
+ ); +}; + +describe('DialogManagerContext', () => { + describe('DialogManagerProvider', () => { + it('creates a new dialog manager when no id is provided with randomly generated id', () => { + render( + + + , + ); + + expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0'); + expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toEqual( + expect.any(String), + ); + }); + + it('creates a new dialog manager and adds it to the manager pool when id is provided', () => { + render( + + + , + ); + + expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toBe( + TEST_MANAGER_ID, + ); + expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0'); + }); + + it('provides dialog manager to non-child components', () => { + render( + + + + , + ); + expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toBe( + MANAGER_2_ID, + ); + expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0'); + }); + + it('removes the dialog manager from the pool upon unmount', () => { + const { rerender } = render( + + + , + ); + + const managerId = screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent; + expect(managerId).toBe(TEST_MANAGER_ID); + + rerender( + + + , + ); + + expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY)).toHaveTextContent( + 'different-manager', + ); + expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT)).toHaveTextContent('0'); + }); + + it('retrieves the existing dialog manager and does not create a new dialog manager', () => { + const dialogId = 'shared-dialog'; + render( + + + + + + + , + ); + + const component1 = screen.getByTestId('component-1'); + const component2 = screen.getByTestId('component-2'); + + expect( + component1.querySelector(`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"`), + ).toHaveTextContent(SHARED_MANAGER_ID); + expect( + component2.querySelector(`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"`), + ).toHaveTextContent(SHARED_MANAGER_ID); + + act(() => { + fireEvent.click(screen.getByTestId(TEST_IDS.OPEN_DIALOG)); + }); + expect( + component1.querySelector(`[data-testid="${TEST_IDS.DIALOG_COUNT}"`), + ).toHaveTextContent('1'); + expect( + component2.querySelector(`[data-testid="${TEST_IDS.DIALOG_COUNT}"`), + ).toHaveTextContent('1'); + expect( + component1.querySelector(`[data-testid="${TEST_IDS.DIALOG_OPEN}"`), + ).toHaveTextContent('true'); + expect( + component2.querySelector(`[data-testid="${TEST_IDS.DIALOG_OPEN}"`), + ).toHaveTextContent('true'); + }); + + it('creates different managers for different IDs', () => { + render( + + + + + + + + , + ); + + const testComponents = screen.getAllByTestId(TEST_IDS.TEST_COMPONENT); + expect(testComponents).toHaveLength(2); + + const manager1Id = testComponents[0].querySelector( + `[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"]`, + ).textContent; + const manager2Id = testComponents[1].querySelector( + `[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"]`, + ).textContent; + + expect(manager1Id).toBe(MANAGER_1_ID); + expect(manager2Id).toBe(MANAGER_2_ID); + + act(() => { + screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[0].click(); + }); + + const manager1Count = testComponents[0].querySelector( + `[data-testid="${TEST_IDS.DIALOG_COUNT}"]`, + ).textContent; + const manager2Count = testComponents[1].querySelector( + `[data-testid="${TEST_IDS.DIALOG_COUNT}"]`, + ).textContent; + + expect(manager1Count).toBe('1'); + expect(manager2Count).toBe('0'); + + act(() => { + screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[1].click(); + }); + + const manager1CountAfter = testComponents[0].querySelector( + `[data-testid="${TEST_IDS.DIALOG_COUNT}"]`, + ).textContent; + const manager2CountAfter = testComponents[1].querySelector( + `[data-testid="${TEST_IDS.DIALOG_COUNT}"]`, + ).textContent; + + expect(manager1CountAfter).toBe('1'); + expect(manager2CountAfter).toBe('1'); + }); + + it('does not retrieve dialog manager only by dialog id', async () => { + render( + + + + + + , + ); + + await act(() => { + fireEvent.click(screen.getByTestId(TEST_IDS.OPEN_DIALOG)); + }); + + await waitFor(async () => { + expect(await screen.findByTestId(TEST_IDS.DIALOG_COUNT)).toHaveTextContent('0'); + const managerId = screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent; + expect(managerId).toBe(MANAGER_2_ID); + }); + }); + + it('uses the manager from the nearest context provider when manager is not found by id', () => { + render( + + + , + ); + + expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY)).toHaveTextContent( + MANAGER_1_ID, + ); + }); + }); +}); diff --git a/src/components/Dialog/hooks/useDialog.ts b/src/components/Dialog/hooks/useDialog.ts index c5fa8a8757..73881effb2 100644 --- a/src/components/Dialog/hooks/useDialog.ts +++ b/src/components/Dialog/hooks/useDialog.ts @@ -1,11 +1,15 @@ import { useCallback, useEffect } from 'react'; -import { useDialogManager } from '../../../context'; +import { modalDialogManagerId, useDialogManager } from '../../../context'; import { useStateStore } from '../../../store'; import type { DialogManagerState, GetOrCreateDialogParams } from '../DialogManager'; -export const useDialog = ({ id }: GetOrCreateDialogParams) => { - const { dialogManager } = useDialogManager(); +export type UseDialogParams = GetOrCreateDialogParams & { + dialogManagerId?: string; +}; + +export const useDialog = ({ dialogManagerId, id }: UseDialogParams) => { + const { dialogManager } = useDialogManager({ dialogManagerId }); useEffect( () => () => { @@ -21,8 +25,13 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => { return dialogManager.getOrCreate({ id }); }; -export const useDialogIsOpen = (id: string) => { - const { dialogManager } = useDialogManager(); +export const modalDialogId = 'modal-dialog' as const; + +export const useModalDialog = () => + useDialog({ dialogManagerId: modalDialogManagerId, id: modalDialogId }); + +export const useDialogIsOpen = (id: string, dialogManagerId?: string) => { + const { dialogManager } = useDialogManager({ dialogManagerId }); const dialogIsOpenSelector = useCallback( ({ dialogsById }: DialogManagerState) => ({ isOpen: !!dialogsById[id]?.isOpen }), [id], @@ -30,6 +39,9 @@ export const useDialogIsOpen = (id: string) => { return useStateStore(dialogManager.state, dialogIsOpenSelector).isOpen; }; +export const useModalDialogIsOpen = () => + useDialogIsOpen(modalDialogId, modalDialogManagerId); + const openedDialogCountSelector = (nextValue: DialogManagerState) => ({ openedDialogCount: Object.values(nextValue.dialogsById).reduce((count, dialog) => { if (dialog.isOpen) return count + 1; @@ -37,7 +49,9 @@ const openedDialogCountSelector = (nextValue: DialogManagerState) => ({ }, 0), }); -export const useOpenedDialogCount = () => { - const { dialogManager } = useDialogManager(); +export const useOpenedDialogCount = ({ + dialogManagerId, +}: { dialogManagerId?: string } = {}) => { + const { dialogManager } = useDialogManager({ dialogManagerId }); return useStateStore(dialogManager.state, openedDialogCountSelector).openedDialogCount; }; diff --git a/src/components/Gallery/Gallery.tsx b/src/components/Gallery/Gallery.tsx index 0f4b12826f..98974283d3 100644 --- a/src/components/Gallery/Gallery.tsx +++ b/src/components/Gallery/Gallery.tsx @@ -4,7 +4,7 @@ import { sanitizeUrl } from '@braintree/sanitize-url'; import clsx from 'clsx'; import { BaseImage as DefaultBaseImage } from './BaseImage'; -import { Modal } from '../Modal'; +import { Modal as DefaultModal } from '../Modal'; import { ModalGallery as DefaultModalGallery } from './ModalGallery'; import { useComponentContext } from '../../context/ComponentContext'; @@ -29,8 +29,11 @@ const UnMemoizedGallery = (props: GalleryProps) => { const [index, setIndex] = useState(0); const [modalOpen, setModalOpen] = useState(false); - const { BaseImage = DefaultBaseImage, ModalGallery = DefaultModalGallery } = - useComponentContext('Gallery'); + const { + BaseImage = DefaultBaseImage, + Modal = DefaultModal, + ModalGallery = DefaultModalGallery, + } = useComponentContext('Gallery'); const { t } = useTranslationContext('Gallery'); const imageFallbackTitle = t('User uploaded content'); diff --git a/src/components/Gallery/Image.tsx b/src/components/Gallery/Image.tsx index 4cba3b38dd..097a308256 100644 --- a/src/components/Gallery/Image.tsx +++ b/src/components/Gallery/Image.tsx @@ -1,9 +1,10 @@ import type { CSSProperties, MutableRefObject } from 'react'; +import { useCallback } from 'react'; import React, { useState } from 'react'; import { sanitizeUrl } from '@braintree/sanitize-url'; import { BaseImage as DefaultBaseImage } from './BaseImage'; -import { Modal } from '../Modal'; +import { Modal as DefaultModal } from '../Modal'; import { ModalGallery as DefaultModalGallery } from './ModalGallery'; import { useComponentContext } from '../../context'; @@ -42,12 +43,20 @@ export const ImageComponent = (props: ImageProps) => { } = props; const [modalIsOpen, setModalIsOpen] = useState(false); - const { BaseImage = DefaultBaseImage, ModalGallery = DefaultModalGallery } = - useComponentContext('ImageComponent'); + const { + BaseImage = DefaultBaseImage, + Modal = DefaultModal, + ModalGallery = DefaultModalGallery, + } = useComponentContext('ImageComponent'); const imageSrc = sanitizeUrl(previewUrl || image_url || thumb_url); + const closeModal = useCallback(() => { + setModalIsOpen(false); + }, []); - const toggleModal = () => setModalIsOpen((modalIsOpen) => !modalIsOpen); + const openModal = useCallback(() => { + setModalIsOpen(true); + }, []); return ( <> @@ -55,7 +64,7 @@ export const ImageComponent = (props: ImageProps) => { alt={fallback} className='str-chat__message-attachment--img' data-testid='image-test' - onClick={toggleModal} + onClick={openModal} src={imageSrc} style={style} tabIndex={0} @@ -63,7 +72,7 @@ export const ImageComponent = (props: ImageProps) => { {...dimensions} {...(innerRef && { ref: innerRef })} /> - + diff --git a/src/components/MessageActions/__tests__/MessageActions.test.js b/src/components/MessageActions/__tests__/MessageActions.test.js index 312faa45be..ce9691c5f9 100644 --- a/src/components/MessageActions/__tests__/MessageActions.test.js +++ b/src/components/MessageActions/__tests__/MessageActions.test.js @@ -108,12 +108,6 @@ describe(' 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/MessageBounce/MessageBounceModal.tsx b/src/components/MessageBounce/MessageBounceModal.tsx index c62db8052a..ff6c435fcd 100644 --- a/src/components/MessageBounce/MessageBounceModal.tsx +++ b/src/components/MessageBounce/MessageBounceModal.tsx @@ -1,8 +1,8 @@ import type { ComponentType, PropsWithChildren } from 'react'; import React from 'react'; import type { ModalProps } from '../Modal'; -import { Modal } from '../Modal'; -import { MessageBounceProvider } from '../../context'; +import { Modal as DefaultModal } from '../Modal'; +import { MessageBounceProvider, useComponentContext } from '../../context'; import type { MessageBouncePromptProps } from './MessageBouncePrompt'; export type MessageBounceModalProps = PropsWithChildren< @@ -15,6 +15,7 @@ export function MessageBounceModal({ MessageBouncePrompt, ...modalProps }: MessageBounceModalProps) { + const { Modal = DefaultModal } = useComponentContext(); return ( diff --git a/src/components/MessageInput/AttachmentSelector.tsx b/src/components/MessageInput/AttachmentSelector.tsx index 83d1539b7b..1632fc1ae7 100644 --- a/src/components/MessageInput/AttachmentSelector.tsx +++ b/src/components/MessageInput/AttachmentSelector.tsx @@ -4,7 +4,7 @@ import { useAttachmentManagerState } from './hooks/useAttachmentManagerState'; import { CHANNEL_CONTAINER_ID } from '../Channel/constants'; import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog'; import { DialogMenuButton } from '../Dialog/DialogMenu'; -import { Modal } from '../Modal'; +import { Modal as DefaultModal } from '../Modal'; import { ShareLocationDialog as DefaultLocationDialog } from '../Location'; import { PollCreationDialog as DefaultPollCreationDialog } from '../Poll'; import { Portal } from '../Portal/Portal'; @@ -192,6 +192,7 @@ export const AttachmentSelector = ({ getModalPortalDestination, }: AttachmentSelectorProps) => { const { t } = useTranslationContext(); + const { Modal = DefaultModal } = useComponentContext(); const { channelCapabilities } = useChannelStateContext(); const messageComposer = useMessageComposer(); diff --git a/src/components/MessageInput/EditMessageForm.tsx b/src/components/MessageInput/EditMessageForm.tsx index aa224f633c..c80ebd7e89 100644 --- a/src/components/MessageInput/EditMessageForm.tsx +++ b/src/components/MessageInput/EditMessageForm.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect } from 'react'; import { MessageInput } from './MessageInput'; import { MessageInputFlat } from './MessageInputFlat'; -import { Modal } from '../Modal'; +import { Modal as DefaultModal } from '../Modal'; import { useComponentContext, useMessageContext, @@ -71,7 +71,8 @@ export const EditMessageForm = () => { export const EditMessageModal = ({ additionalMessageInputProps, }: Pick) => { - const { EditMessageInput = EditMessageForm } = useComponentContext(); + const { EditMessageInput = EditMessageForm, Modal = DefaultModal } = + useComponentContext(); const { clearEditingState } = useMessageContext(); const messageComposer = useMessageComposer(); const onEditModalClose = useCallback(() => { 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', () => {
Custom head
-
`); }); @@ -189,12 +159,6 @@ describe('VirtualizedMessageComponents', () => {
Custom head
-
`); }); @@ -218,12 +182,6 @@ describe('VirtualizedMessageComponents', () => { Custom Loading Indicator
-
`); }); @@ -244,16 +202,7 @@ describe('VirtualizedMessageComponents', () => { const { container } = renderElements( , ); - expect(container).toMatchInlineSnapshot(` -
-
-
- `); + expect(container).toBeEmptyDOMElement(); }); it('should render custom EmptyStateIndicator for main message list', () => { const { container } = renderElements(, componentContext); @@ -271,16 +220,7 @@ describe('VirtualizedMessageComponents', () => { it('should render empty if EmptyStateIndicator nullified', () => { const componentContext = { EmptyStateIndicator: NullEmptyStateIndicator }; const { container } = renderElements(, componentContext); - expect(container).toMatchInlineSnapshot(` -
-
-
- `); + expect(container).toBeEmptyDOMElement(); }); it('should render empty in thread if EmptyStateIndicator nullified', () => { @@ -289,16 +229,7 @@ describe('VirtualizedMessageComponents', () => { , componentContext, ); - expect(container).toMatchInlineSnapshot(` -
-
-
- `); + 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"