diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx index 15cd52a74..50aff629f 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { NotificationSeverity } from 'stream-chat'; import { Button, + GlobalModal, IconArrowDown, IconArrowLeft, IconArrowUp, @@ -24,6 +25,7 @@ import { useDialogIsOpen, useDialogOnNearestManager, useNotificationApi, + Viewer, } from 'stream-chat-react'; import { DraggableDialog } from './DraggableDialog'; import { @@ -75,11 +77,13 @@ const isDraftActionReady = ( ) => action.label.trim().length > 0 && action.feedback.trim().length > 0; const NotificationEntrySelect = ({ + disabled, label, onChange, options, value, }: { + disabled?: boolean; label: string; onChange: (value: string) => void; options: readonly string[]; @@ -89,6 +93,7 @@ const NotificationEntrySelect = ({ {label} { + if (event.target.checked) { + onChange([...value, option]); + return; + } + + onChange(value.filter((panel) => panel !== option)); + }} + type='checkbox' + /> + {option} + + ))} + + +); + const NotificationChipList = ({ notifications, + publishQueuedNotification, removeQueuedNotification, }: { notifications: QueuedNotification[]; + publishQueuedNotification: (notification: QueuedNotification) => void; removeQueuedNotification: (id: string) => void; }) => { const [tooltipState, setTooltipState] = useState<{ @@ -160,7 +200,7 @@ const NotificationChipList = ({ {notification.entryDirection} - {notification.targetPanel} + {notification.targetPanels.join(', ')} {notification.actions.length > 0 && ( @@ -168,6 +208,16 @@ const NotificationChipList = ({ )} + + + + - setDraft((current) => ({ ...current, - entryDirection: value as NotificationListEnterFrom, + targetPanels: value, })) } - options={entryDirectionOptions} - value={draft.entryDirection} + options={targetPanelOptions} + value={draft.targetPanels} /> setDraft((current) => ({ ...current, - targetPanel: value as NotificationTargetPanel, + entryDirection: value as NotificationListEnterFrom, })) } - options={targetPanelOptions} - value={draft.targetPanel} + options={entryDirectionOptions} + value={draft.entryDirection} />
@@ -369,10 +449,19 @@ const NotificationDraftForm = ({
+ ( [], ); + const [closeOnSubmit, setCloseOnSubmit] = useState(false); + const [globalModalOpen, setGlobalModalOpen] = useState(false); const chipIdRef = useRef(0); const { addNotification } = useNotificationApi(); const { dialog, dialogManager } = useDialogOnNearestManager({ @@ -422,6 +513,7 @@ export const NotificationPromptDialog = ({ const resetState = useCallback(() => { setDraft(createInitialDraftState()); setQueuedNotifications([]); + setCloseOnSubmit(false); }, [createInitialDraftState]); useEffect(() => { @@ -439,13 +531,15 @@ export const NotificationPromptDialog = ({ actions: buildNotificationActions(notification), context: { entryDirection: notification.entryDirection, - panel: notification.targetPanel, + ...(notification.targetPanels.length === 1 + ? { panel: notification.targetPanels[0] } + : { targetPanels: notification.targetPanels }), }, duration: notification.duration, emitter: 'vite-preview/ActionsMenu', message: notification.message, severity: notification.severity, - targetPanels: [notification.targetPanel], + targetPanels: notification.targetPanels, }); }, [addNotification], @@ -472,7 +566,7 @@ export const NotificationPromptDialog = ({ id: `queued-notification-${chipIdRef.current}`, message: draft.message.trim(), severity: draft.severity as NotificationSeverity, - targetPanel: draft.targetPanel as NotificationTargetPanel, + targetPanels: draft.targetPanels, }, ]); setDraft(createInitialDraftState()); @@ -480,13 +574,25 @@ export const NotificationPromptDialog = ({ const registerQueuedNotifications = useCallback(() => { queuedNotifications.forEach(publishNotification); - closeDialog(); - }, [closeDialog, publishNotification, queuedNotifications]); + setQueuedNotifications([]); + + if (closeOnSubmit) { + closeDialog(); + } + }, [closeDialog, closeOnSubmit, publishNotification, queuedNotifications]); const removeQueuedNotification = useCallback((id: string) => { setQueuedNotifications((current) => current.filter((item) => item.id !== id)); }, []); + const publishQueuedNotification = useCallback( + (notification: QueuedNotification) => { + publishNotification(notification); + removeQueuedNotification(notification.id); + }, + [publishNotification, removeQueuedNotification], + ); + const addDraftAction = useCallback(() => { setDraft((current) => ({ ...current, @@ -536,29 +642,47 @@ export const NotificationPromptDialog = ({ ); return ( - - - + <> + setGlobalModalOpen(false)} open={globalModalOpen}> + +

GlobalModal notification preview

+

+ Keep the trigger dialog open and submit notifications while this modal is + visible. +

+ +
+
+ + setGlobalModalOpen(true)} + publishQueuedNotification={publishQueuedNotification} + queueCurrentDraft={queueCurrentDraft} + queuedNotifications={queuedNotifications} + registerQueuedNotifications={registerQueuedNotifications} + removeQueuedNotification={removeQueuedNotification} + setCloseOnSubmit={setCloseOnSubmit} + setDraft={setDraft} + toggleDraftActionInPayload={toggleDraftActionInPayload} + updateDraftAction={updateDraftAction} + /> + + ); }; diff --git a/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts b/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts index c4cc850e0..6a1e1dc79 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts +++ b/examples/vite/src/AppSettings/ActionsMenu/triggerNotificationUtils.ts @@ -24,6 +24,7 @@ export const targetPanelOptions = [ 'thread', 'channel-list', 'thread-list', + 'modal', ] as const satisfies NotificationTargetPanel[]; export type NotificationDraftAction = { @@ -44,7 +45,7 @@ export type NotificationDraft = { entryDirection: NotificationListEnterFrom | ''; message: string; severity: NotificationSeverity | ''; - targetPanel: NotificationTargetPanel | ''; + targetPanels: NotificationTargetPanel[]; }; export type QueuedNotification = { @@ -54,16 +55,16 @@ export type QueuedNotification = { id: string; message: string; severity: NotificationSeverity; - targetPanel: NotificationTargetPanel; + targetPanels: NotificationTargetPanel[]; }; export const initialDraft: NotificationDraft = { actions: [], duration: '5000', entryDirection: 'bottom', - message: '', + message: 'This is a test notification', severity: 'info', - targetPanel: 'channel', + targetPanels: ['channel'], }; export const parseDuration = (value: string) => { @@ -76,7 +77,7 @@ export const isDraftReady = (draft: NotificationDraft) => draft.message.trim() && draft.severity && draft.entryDirection && - draft.targetPanel && + draft.targetPanels.length > 0 && parseDuration(draft.duration) !== null, ); diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index a79e2133c..80965713a 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -38,11 +38,6 @@ max-width: min(320px, calc(100vw - 32px)); } - .app__notification-dialog { - min-width: min(420px, calc(100vw - 32px)); - max-width: min(420px, calc(100vw - 32px)); - } - .app__attachment-dialog { min-width: min(480px, calc(100vw - 32px)); max-width: min(480px, calc(100vw - 32px)); @@ -61,8 +56,8 @@ .app__notification-dialog__prompt { display: flex; flex-direction: column; - width: min(420px, calc(100vw - 32px)); - max-height: min(500px, calc(100vh - 32px)); + width: min(620px, calc(100vw - 32px)); + max-height: min(720px, calc(100vh - 32px)); overflow: hidden; } @@ -694,6 +689,34 @@ background: var(--str-chat__background-core-elevation-2); color: var(--str-chat__text-primary); font: inherit; + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } + } + + .app__notification-dialog__target-panel-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .app__notification-dialog__target-panel-option { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--str-chat__border-core-default); + border-radius: 999px; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + cursor: pointer; + font-size: 13px; + } + + .app__notification-dialog__open-modal-button { + width: fit-content; } .app__notification-dialog__text-input { @@ -704,6 +727,36 @@ grid-column: 1 / -1; } + .app__notification-dialog__duration-controls { + display: flex; + align-items: center; + gap: 8px; + + .str-chat__form-input-field { + flex: 1 1 auto; + min-width: 0; + } + } + + .app__notification-dialog__permanent-duration-button { + flex: 0 0 auto; + white-space: nowrap; + } + + .app__notification-dialog__global-modal-preview { + display: flex; + flex-direction: column; + gap: 32px; + padding: 24px; + text-align: center; + background-color: var(--str-chat__background-core-elevation-1); + + h2, + p { + margin: 0; + } + } + .app__notification-dialog__actions { display: grid; gap: 12px; @@ -860,6 +913,10 @@ font-style: italic; } + .app__notification-dialog__chip-trigger { + flex-shrink: 0; + } + .app__notification-dialog__chip-remove { color: var(--str-chat__text-secondary); } @@ -873,11 +930,27 @@ } .app__notification-dialog__footer-controls { + display: flex; + align-items: center; + gap: 12px; align-self: flex-end; flex-shrink: 0; margin-inline-start: 0; } + .app__notification-dialog__close-on-submit { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--str-chat__text-primary); + cursor: pointer; + font-size: 13px; + } + + .str-chat__dialog-overlay:has(.app__notification-dialog) { + z-index: 101; + } + @media (max-width: 560px) { .app__notification-dialog__queue-controls { width: 100%; diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index e5152c24c..e178b9c5c 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -9,6 +9,12 @@ import { } from 'stream-chat'; import { NotificationAnnouncer as DefaultNotificationAnnouncer } from '../Accessibility'; +import { useModalDialogIsOpen } from '../Dialog'; +import { + getNotificationTargetPanels, + NotificationConfigurationProvider, + type NotificationDisplayFilter, +} from '../Notifications'; import { useChat } from './hooks/useChat'; import { useReportLostConnectionSystemNotification } from './hooks/useReportLostConnectionSystemNotification'; import { useCreateChatContext } from './hooks/useCreateChatContext'; @@ -26,6 +32,40 @@ const NetworkConnectionNotificationReporter = () => { return null; }; +const createDefaultNotificationDisplayFilter = + (modalIsOpen: boolean): NotificationDisplayFilter => + ({ notification, panel }) => { + const targetPanels = getNotificationTargetPanels(notification); + + if (targetPanels.includes('modal')) { + return panel === 'modal'; + } + + if (!modalIsOpen) return true; + + return panel === 'modal'; + }; + +const ModalNotificationConfiguration = ({ + children, + notificationDisplayFilter, +}: PropsWithChildren<{ + notificationDisplayFilter?: NotificationDisplayFilter; +}>) => { + const modalIsOpen = useModalDialogIsOpen(); + const displayFilter = useMemo( + () => + notificationDisplayFilter ?? createDefaultNotificationDisplayFilter(modalIsOpen), + [modalIsOpen, notificationDisplayFilter], + ); + + return ( + + {children} + + ); +}; + export type ChatProps = { /** The StreamChat client object */ client: StreamChat; @@ -37,6 +77,8 @@ export type ChatProps = { i18nInstance?: Streami18n; /** Instance of SearchController class that allows to control all the search operations. */ searchController?: SearchController; + /** Controls whether a notification can be displayed by a NotificationList. */ + notificationDisplayFilter?: NotificationDisplayFilter; /** Used for injecting className/s to the Channel and ChannelList components */ theme?: string; /** @@ -61,6 +103,7 @@ export const Chat = (props: PropsWithChildren) => { defaultLanguage, i18nInstance, isMessageAIGenerated, + notificationDisplayFilter, searchController: customChannelSearchController, theme = 'messaging light', useImageFlagEmojisOnWindows = false, @@ -116,9 +159,13 @@ export const Chat = (props: PropsWithChildren) => { - - - {children} + + + + {children} + diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index 5fca470d1..61927170d 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -7,7 +7,9 @@ import { Chat } from '..'; import { ChatContext, ComponentProvider, TranslationContext } from '../../../context'; import type { ChatContextValue } from '../../../context'; +import { useNotificationConfigurationContext } from '../../Notifications'; import { Streami18n } from '../../../i18n'; +import type { Notification } from 'stream-chat'; import type { Mute } from 'stream-chat'; import { dispatchConnectionChangedEvent, @@ -26,6 +28,21 @@ const TranslationContextConsumer = ({ fn }) => { return
; }; +const NotificationDisplayFilterConsumer = ({ fn }) => { + fn(useNotificationConfigurationContext().displayFilter); + return
; +}; + +const notification = (tags: string[]) => + ({ + createdAt: 1, + id: 'n-1', + message: 'test', + origin: { emitter: 'test' }, + severity: 'info', + tags, + }) as Notification; + describe('Chat', () => { afterEach(cleanup); const chatClient = getTestClient(); @@ -43,6 +60,35 @@ describe('Chat', () => { await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument()); }); + it('keeps modal-targeted notifications exclusive to the modal panel by default', async () => { + let displayFilter: ReturnType< + typeof useNotificationConfigurationContext + >['displayFilter']; + + await act(() => { + render( + + { + displayFilter = filter; + }} + /> + , + ); + }); + + await waitFor(() => { + const modalNotification = notification(['target:modal', 'target:channel']); + + expect(displayFilter({ notification: modalNotification, panel: 'channel' })).toBe( + false, + ); + expect(displayFilter({ notification: modalNotification, panel: 'modal' })).toBe( + true, + ); + }); + }); + it('should expose the context', async () => { let context: ChatContextValue; await act(() => { diff --git a/src/components/Form/__tests__/Dropdown.test.tsx b/src/components/Form/__tests__/Dropdown.test.tsx index e3c65a736..bd45de709 100644 --- a/src/components/Form/__tests__/Dropdown.test.tsx +++ b/src/components/Form/__tests__/Dropdown.test.tsx @@ -2,9 +2,17 @@ import React, { act } from 'react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Dropdown, type DropdownTriggerProps, useDropdownContext } from '../Dropdown'; import { GlobalModal, type ModalProps } from '../../Modal/GlobalModal'; -import { ChatProvider, ModalDialogManagerProvider } from '../../../context'; +import { + ChatProvider, + ComponentProvider, + ModalDialogManagerProvider, +} from '../../../context'; import { mockChatContext } from '../../../mock-builders'; +import type { NotificationListProps } from '../../Notifications'; + +const NoopNotificationList: React.ComponentType = () => null; + const TriggerButton = ({ children, onClick, @@ -153,20 +161,22 @@ const renderDropdownInModal = ({ } = {}) => render( - - -
- - - - - -
-
-
+ + + +
+ + + + + +
+
+
+
, ); diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx index 03fde62ad..2f5123212 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -10,10 +10,12 @@ import React, { } from 'react'; import { FocusScope } from '@react-aria/focus'; +import { NotificationList as DefaultNotificationList } from '../Notifications'; import { ModalContextProvider, modalDialogManagerId, useChatContext, + useComponentContext, } from '../../context'; import { DialogPortalEntry, @@ -69,6 +71,7 @@ export const GlobalModal = ({ const closeButtonRef = useRef(null); const closingRef = useRef(false); const { theme } = useChatContext(); + const { NotificationList = DefaultNotificationList } = useComponentContext(); const dialogLabelingBaseId = dialog.id; const resolvedModalAriaProps = useResolvedModalAriaProps({ ariaDescribedby, @@ -153,6 +156,11 @@ export const GlobalModal = ({ {children}
+ {CloseButtonOnOverlay && ( )} diff --git a/src/components/Modal/__tests__/GlobalModal.test.tsx b/src/components/Modal/__tests__/GlobalModal.test.tsx index 118d360c6..02c59594a 100644 --- a/src/components/Modal/__tests__/GlobalModal.test.tsx +++ b/src/components/Modal/__tests__/GlobalModal.test.tsx @@ -2,17 +2,34 @@ import React from 'react'; import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import { GlobalModal } from '../GlobalModal'; -import { ChatProvider, ModalDialogManagerProvider } from '../../../context'; +import { + ChatProvider, + ComponentProvider, + ModalDialogManagerProvider, +} from '../../../context'; import { mockChatContext } from '../../../mock-builders'; import { axe } from '../../../../axe-helper'; +import type { NotificationListProps } from '../../Notifications'; + const OVERLAY_SELECTOR = '.str-chat__modal'; -const renderComponent = ({ props }: any = {}) => +const NoopNotificationList: React.ComponentType = () => null; +const renderComponent = ({ + components, + props, +}: { + components?: React.ComponentProps['value']; + props?: React.ComponentProps; +} = {}) => render( - - - + + + + + , ); @@ -67,6 +84,34 @@ describe('GlobalModal', () => { expect(queryByText(textContent)).toBeInTheDocument(); }); + it('renders notifications relative to the modal overlay', () => { + const ModalNotificationList = ({ + className, + panel, + verticalAlignment, + }: NotificationListProps) => ( +
+ ); + + renderComponent({ + components: { NotificationList: ModalNotificationList }, + props: { children: , open: true }, + }); + + const notificationList = screen.getByTestId('modal-notification-list'); + + expect(notificationList).toHaveClass('str-chat__modal__notification-list'); + expect(notificationList).toHaveAttribute('data-panel', 'modal'); + expect(notificationList).toHaveAttribute('data-vertical-alignment', 'top'); + expect(document.querySelector(OVERLAY_SELECTOR)).toContainElement(notificationList); + expect(screen.getByRole('dialog')).not.toContainElement(notificationList); + }); + it('should call the onClose prop function if the escape key is pressed', () => { const onClose = vi.fn(); renderComponent({ diff --git a/src/components/Notifications/NotificationConfigurationContext.tsx b/src/components/Notifications/NotificationConfigurationContext.tsx new file mode 100644 index 000000000..65fe02bef --- /dev/null +++ b/src/components/Notifications/NotificationConfigurationContext.tsx @@ -0,0 +1,63 @@ +import React, { useContext, useMemo } from 'react'; + +import type { Notification } from 'stream-chat'; +import type { PropsWithChildrenOnly } from '../../types/types'; +import type { NotificationTargetPanel } from './notificationTarget'; +import type { NotificationListFilter } from './NotificationList'; + +export type NotificationDisplayFilterParams = { + /** Fallback panel used by the receiving NotificationList when a notification has no explicit target. */ + fallbackPanel?: NotificationTargetPanel; + /** Local NotificationList filter. Runs only after the display filter accepts the notification. */ + filter?: NotificationListFilter; + /** Notification being evaluated after panel/fallback routing matched it to this list. */ + notification: Notification; + /** Panel of the NotificationList currently evaluating the notification. */ + panel?: NotificationTargetPanel; +}; + +export type NotificationDisplayFilter = ( + params: NotificationDisplayFilterParams, +) => boolean; + +export type NotificationConfigurationContextValue = { + displayFilter: NotificationDisplayFilter; +}; + +const defaultNotificationDisplayFilter: NotificationDisplayFilter = () => true; + +const defaultNotificationConfigurationContextValue: NotificationConfigurationContextValue = + { + displayFilter: defaultNotificationDisplayFilter, + }; + +const NotificationConfigurationContext = + React.createContext( + defaultNotificationConfigurationContextValue, + ); + +export type NotificationConfigurationProviderProps = PropsWithChildrenOnly & { + displayFilter?: NotificationDisplayFilter; +}; + +export const NotificationConfigurationProvider = ({ + children, + displayFilter, +}: NotificationConfigurationProviderProps) => { + const parentConfiguration = useContext(NotificationConfigurationContext); + const value = useMemo( + () => ({ + displayFilter: displayFilter ?? parentConfiguration.displayFilter, + }), + [displayFilter, parentConfiguration.displayFilter], + ); + + return ( + + {children} + + ); +}; + +export const useNotificationConfigurationContext = () => + useContext(NotificationConfigurationContext); diff --git a/src/components/Notifications/NotificationList.tsx b/src/components/Notifications/NotificationList.tsx index 0d2c83582..30e221f52 100644 --- a/src/components/Notifications/NotificationList.tsx +++ b/src/components/Notifications/NotificationList.tsx @@ -278,6 +278,7 @@ export const NotificationList = ({ [filter], ); const notifications = useNotifications({ + applyDisplayFilter: true, fallbackPanel, filter: combinedFilter, panel, diff --git a/src/components/Notifications/__tests__/NotificationList.test.tsx b/src/components/Notifications/__tests__/NotificationList.test.tsx index e4c495abf..b50431822 100644 --- a/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { createDefaultPickNext, NotificationList, pickNewest } from '../NotificationList'; import { useNotificationApi } from '../hooks/useNotificationApi'; import { useNotifications } from '../hooks/useNotifications'; +import { isNotificationForPanel } from '../notificationTarget'; import { ComponentProvider } from '../../../context/ComponentContext'; import type { Notification } from 'stream-chat'; @@ -13,6 +14,9 @@ vi.mock('../hooks/useNotifications', () => ({ })); vi.mock('../hooks/useNotificationApi', () => ({ + hasSystemNotificationTag: vi.fn( + (notification: Notification) => notification.tags?.includes('system') ?? false, + ), useNotificationApi: vi.fn(), })); @@ -127,8 +131,19 @@ describe('NotificationList', () => { (notification) => notification.id !== id, ); }); - mockedUseNotifications.mockImplementation(() => currentNotifications); - window.IntersectionObserver = IntersectionObserverMock as any; + mockedUseNotifications.mockImplementation((options) => { + const byPanel = options?.panel + ? currentNotifications.filter((notification) => + isNotificationForPanel(notification, options.panel, { + fallbackPanel: options.fallbackPanel, + }), + ) + : currentNotifications; + + return options?.filter ? byPanel.filter(options.filter) : byPanel; + }); + window.IntersectionObserver = + IntersectionObserverMock as unknown as typeof IntersectionObserver; }); afterEach(() => { @@ -171,6 +186,32 @@ describe('NotificationList', () => { expect(startTimeout).toHaveBeenNthCalledWith(1, 'n-1'); }); + it('shows untargeted notifications in the channel panel by default', () => { + currentNotifications = [transientFixture()]; + + render(); + + expect(screen.getByTestId('notification-n-1')).toBeInTheDocument(); + }); + + it('shows explicitly channel-targeted notifications in the channel panel', () => { + currentNotifications = [transientFixture({ tags: ['target:channel'] })]; + + render(); + + expect(screen.getByTestId('notification-n-1')).toBeInTheDocument(); + }); + + it('keeps existing targeted panels working', () => { + currentNotifications = [ + transientFixture({ id: 'n-channel-list', tags: ['target:channel-list'] }), + ]; + + render(); + + expect(screen.getByTestId('notification-n-channel-list')).toBeInTheDocument(); + }); + it('shows the oldest queued notification first (FIFO) when multiple are already queued at mount', () => { currentNotifications = [ transientFixture({ createdAt: 1, id: 'n-1', message: 'Old' }), diff --git a/src/components/Notifications/__tests__/notificationOrigin.test.ts b/src/components/Notifications/__tests__/notificationOrigin.test.ts index 6fbdd1324..7013ca943 100644 --- a/src/components/Notifications/__tests__/notificationOrigin.test.ts +++ b/src/components/Notifications/__tests__/notificationOrigin.test.ts @@ -51,6 +51,7 @@ describe('notificationOrigin helpers', () => { expect(isNotificationTargetPanel('thread')).toBe(true); expect(isNotificationTargetPanel('channel-list')).toBe(true); expect(isNotificationTargetPanel('thread-list')).toBe(true); + expect(isNotificationTargetPanel('modal')).toBe(true); expect(isNotificationTargetPanel('unknown')).toBe(false); }); @@ -78,6 +79,24 @@ describe('notificationOrigin helpers', () => { expect(isNotificationForPanel(notification(), 'thread')).toBe(false); }); + it('supports overriding the fallback panel', () => { + expect( + isNotificationForPanel(notification(), 'thread-list', { + fallbackPanel: 'thread-list', + }), + ).toBe(true); + expect( + isNotificationForPanel(notification(), 'modal', { + fallbackPanel: 'modal', + }), + ).toBe(true); + expect( + isNotificationForPanel(notification(), 'channel', { + fallbackPanel: 'thread-list', + }), + ).toBe(false); + }); + it('matches explicit target panel when present', () => { expect(isNotificationForPanel(notification('thread'), 'thread')).toBe(true); expect(isNotificationForPanel(notification('thread'), 'channel')).toBe(false); diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts index 90e5b95ff..426278b5f 100644 --- a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts @@ -1,8 +1,11 @@ import { renderHook } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; +import { StateStore } from 'stream-chat'; -import { useChatContext } from '../../../../context'; +import { useChatContext, useModalDialogManager } from '../../../../context'; +import { modalDialogId } from '../../../Dialog'; import { useNotificationTarget } from '../useNotificationTarget'; +import type { DialogManagerState } from '../../../Dialog/service/DialogManager'; import type { Notification } from 'stream-chat'; import { @@ -13,6 +16,7 @@ import { vi.mock('../../../../context', () => ({ useChatContext: vi.fn(), + useModalDialogManager: vi.fn(), })); vi.mock('../useNotificationTarget', () => ({ @@ -24,11 +28,22 @@ const remove = vi.fn(); const startTimeout = vi.fn(); const mockedUseChatContext = vi.mocked(useChatContext); +const mockedUseModalDialogManager = vi.mocked(useModalDialogManager); const mockedUseNotificationTarget = vi.mocked(useNotificationTarget); +const createModalDialogManager = (isOpen: boolean) => + fromPartial({ + state: new StateStore({ + dialogsById: { + [modalDialogId]: fromPartial({ isOpen }), + }, + }), + }); + describe('useNotificationApi', () => { beforeEach(() => { - mockedUseNotificationTarget.mockReturnValue('channel'); + mockedUseModalDialogManager.mockReturnValue(createModalDialogManager(false)); + mockedUseNotificationTarget.mockReturnValue(undefined); mockedUseChatContext.mockReturnValue( fromPartial({ client: { @@ -62,6 +77,21 @@ describe('useNotificationApi', () => { expect(startTimeout).toHaveBeenCalledWith('notification-id'); }); + it('does not add target panel tags when targetPanels and inferred panel are missing', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'MessageComposer', + message: 'Send message request failed', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Send message request failed', + options: {}, + origin: { emitter: 'MessageComposer' }, + }); + }); + it('adds inferred target panel tag when targetPanels is not provided', () => { mockedUseNotificationTarget.mockReturnValue('thread'); @@ -104,8 +134,60 @@ describe('useNotificationApi', () => { }); }); - it('allows passing targetPanels as an empty array to skip inferred panel tag', () => { - mockedUseNotificationTarget.mockReturnValue('thread-list'); + it('allows passing targetPanels as an empty array to skip target tags', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'Message', + message: 'Skipped panel tag', + targetPanels: [], + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Skipped panel tag', + options: {}, + origin: { emitter: 'Message' }, + }); + }); + + it('adds the modal target to explicit target panels while a modal is open', () => { + mockedUseModalDialogManager.mockReturnValue(createModalDialogManager(true)); + + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'Message', + message: 'Channel notification above modal', + targetPanels: ['channel'], + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Channel notification above modal', + options: { tags: ['target:channel', 'target:modal'] }, + origin: { emitter: 'Message' }, + }); + }); + + it('adds the modal target to inferred target panel while a modal is open', () => { + mockedUseModalDialogManager.mockReturnValue(createModalDialogManager(true)); + mockedUseNotificationTarget.mockReturnValue('thread'); + + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'MessageComposer', + message: 'Inferred target above modal', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Inferred target above modal', + options: { tags: ['target:thread', 'target:modal'] }, + origin: { emitter: 'MessageComposer' }, + }); + }); + + it('preserves explicitly empty target panels while a modal is open', () => { + mockedUseModalDialogManager.mockReturnValue(createModalDialogManager(true)); const { result } = renderHook(() => useNotificationApi()); @@ -133,7 +215,7 @@ describe('useNotificationApi', () => { expect(add).toHaveBeenCalledWith({ message: 'Heads up', - options: { severity: 'warning', tags: ['target:channel'] }, + options: { severity: 'warning' }, origin: { emitter: 'NotificationPromptDialog' }, }); }); @@ -152,7 +234,6 @@ describe('useNotificationApi', () => { message: 'Edit message request failed', options: { severity: 'error', - tags: ['target:channel'], type: 'api:message:edit:failed', }, origin: { emitter: 'MessageComposer' }, @@ -177,7 +258,6 @@ describe('useNotificationApi', () => { message: 'Failed to share location', options: { severity: 'error', - tags: ['target:channel'], type: 'api:location:share:failed', }, origin: { emitter: 'ShareLocationDialog' }, @@ -203,7 +283,6 @@ describe('useNotificationApi', () => { message: 'Failed to share location', options: { severity: 'error', - tags: ['target:channel'], type: 'custom:type', }, origin: { emitter: 'ShareLocationDialog' }, @@ -229,7 +308,6 @@ describe('useNotificationApi', () => { message: 'Location sharing blocked', options: { severity: 'error', - tags: ['target:channel'], type: 'api:location:share:blocked', }, origin: { emitter: 'ShareLocationDialog' }, @@ -254,7 +332,6 @@ describe('useNotificationApi', () => { message: 'Uploading attachment', options: { severity: 'loading', - tags: ['target:channel'], type: 'api:attachment:upload:loading', }, origin: { emitter: 'Uploader' }, @@ -262,8 +339,6 @@ describe('useNotificationApi', () => { }); it('addSystemNotification applies system tag and skips panel target tags', () => { - mockedUseNotificationTarget.mockReturnValue('thread'); - const { result } = renderHook(() => useNotificationApi()); result.current.addSystemNotification({ diff --git a/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx b/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx new file mode 100644 index 000000000..1c658f693 --- /dev/null +++ b/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { StateStore } from 'stream-chat'; + +import { useNotifications } from '../useNotifications'; +import { NotificationConfigurationProvider } from '../../NotificationConfigurationContext'; +import { ChatProvider } from '../../../../context'; +import { mockChatContext } from '../../../../mock-builders'; + +import type { Notification, NotificationManagerState } from 'stream-chat'; + +const notification = (overrides: Partial = {}): Notification => + ({ + createdAt: 1, + id: 'n-1', + message: 'First', + origin: { emitter: 'test' }, + severity: 'info', + ...overrides, + }) as Notification; + +const renderUseNotifications = ({ + displayFilter, + notifications, + options, +}: { + displayFilter?: React.ComponentProps< + typeof NotificationConfigurationProvider + >['displayFilter']; + notifications: Notification[]; + options?: Parameters[0]; +}) => { + const store = new StateStore({ notifications }); + const client = { notifications: { store } }; + const wrapper = ({ children }: React.PropsWithChildren) => ( + + + {children} + + + ); + + return renderHook(() => useNotifications(options), { wrapper }); +}; + +describe('useNotifications', () => { + it('returns routed notifications that pass the configured display filter', () => { + const result = renderUseNotifications({ + displayFilter: ({ panel }) => panel === 'modal', + notifications: [notification({ tags: ['target:modal'] })], + options: { applyDisplayFilter: true, panel: 'modal' }, + }); + + expect(result.result.current).toHaveLength(1); + }); + + it('filters routed notifications rejected by the configured display filter', () => { + const result = renderUseNotifications({ + displayFilter: ({ panel }) => panel === 'modal', + notifications: [notification({ tags: ['target:channel'] })], + options: { applyDisplayFilter: true, panel: 'channel' }, + }); + + expect(result.result.current).toHaveLength(0); + }); + + it('runs the configured display filter before the local filter', () => { + const localFilter = vi.fn(() => true); + const displayFilter = vi.fn(() => false); + const threadNotification = notification({ tags: ['target:thread'] }); + + const result = renderUseNotifications({ + displayFilter, + notifications: [threadNotification], + options: { + applyDisplayFilter: true, + fallbackPanel: 'thread', + filter: localFilter, + panel: 'thread', + }, + }); + + expect(displayFilter).toHaveBeenCalledWith({ + fallbackPanel: 'thread', + filter: localFilter, + notification: threadNotification, + panel: 'thread', + }); + expect(localFilter).not.toHaveBeenCalled(); + expect(result.result.current).toHaveLength(0); + }); +}); diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts index 027616a74..286ab979e 100644 --- a/src/components/Notifications/hooks/useNotificationApi.ts +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -2,7 +2,9 @@ import { useCallback } from 'react'; import type { Notification, NotificationAction, NotificationSeverity } from 'stream-chat'; -import { useChatContext } from '../../../context'; +import { modalDialogId } from '../../Dialog'; +import { useChatContext, useModalDialogManager } from '../../../context'; +import { useStateStore } from '../../../store'; import { addNotificationTargetTag, getNotificationTargetTag, @@ -10,6 +12,12 @@ import { } from '../notificationTarget'; import { useNotificationTarget } from './useNotificationTarget'; +import type { DialogManagerState } from '../../Dialog/service/DialogManager'; + +const modalDialogIsOpenSelector = ({ dialogsById }: DialogManagerState) => ({ + isOpen: !!dialogsById[modalDialogId]?.isOpen, +}); + /** Tag used for full-width system banners (e.g. connection status). Excluded from `NotificationList` by default. */ export const SYSTEM_NOTIFICATION_TAG = 'system' as const; @@ -81,10 +89,26 @@ const getTargetTags = ( targetPanels: NotificationTargetPanel[] | undefined, inferredPanel: NotificationTargetPanel | undefined, tags: string[] | undefined, + modalIsOpen: boolean, ) => { if (targetPanels) { + const effectiveTargetPanels = + modalIsOpen && targetPanels.length > 0 + ? [...targetPanels, 'modal' as const] + : targetPanels; + + return Array.from( + new Set([...effectiveTargetPanels.map(getNotificationTargetTag), ...(tags ?? [])]), + ); + } + + if (modalIsOpen) { return Array.from( - new Set([...targetPanels.map(getNotificationTargetTag), ...(tags ?? [])]), + new Set([ + ...(inferredPanel ? [getNotificationTargetTag(inferredPanel)] : []), + getNotificationTargetTag('modal'), + ...(tags ?? []), + ]), ); } @@ -111,6 +135,9 @@ const getTypeFromIncident = ({ export const useNotificationApi = (): NotificationApi => { const { client } = useChatContext(); const inferredPanel = useNotificationTarget(); + const modalDialogManager = useModalDialogManager(); + const modalIsOpen = + useStateStore(modalDialogManager?.state, modalDialogIsOpenSelector)?.isOpen ?? false; const addNotification: AddNotification = useCallback( ({ @@ -126,7 +153,12 @@ export const useNotificationApi = (): NotificationApi => { targetPanels, type, }: AddNotificationParams) => { - const notificationTags = getTargetTags(targetPanels, inferredPanel, tags); + const notificationTags = getTargetTags( + targetPanels, + inferredPanel, + tags, + modalIsOpen, + ); const resolvedType = getTypeFromIncident({ incident, severity, type }); const origin = context ? { context, emitter } : { emitter }; @@ -145,7 +177,7 @@ export const useNotificationApi = (): NotificationApi => { origin, }); }, - [client, inferredPanel], + [client, inferredPanel, modalIsOpen], ); const addSystemNotification: AddSystemNotification = useCallback( diff --git a/src/components/Notifications/hooks/useNotifications.ts b/src/components/Notifications/hooks/useNotifications.ts index 62c03b19e..9ede1d74f 100644 --- a/src/components/Notifications/hooks/useNotifications.ts +++ b/src/components/Notifications/hooks/useNotifications.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useChatContext } from '../../../context'; import { useStateStore } from '../../../store'; import type { Notification, NotificationManagerState } from 'stream-chat'; +import { useNotificationConfigurationContext } from '../NotificationConfigurationContext'; import { isNotificationForPanel } from '../notificationTarget'; import type { NotificationTargetPanel } from '../notificationTarget'; @@ -9,6 +10,11 @@ import type { NotificationTargetPanel } from '../notificationTarget'; export type UseNotificationsFilter = (notification: Notification) => boolean; export type UseNotificationsOptions = { + /** + * When true, the configured Chat-level notificationDisplayFilter is applied after + * panel routing and before the local filter. + */ + applyDisplayFilter?: boolean; /** * When provided, only notifications that pass this filter are returned. * Use to have a given NotificationList consume only a subset of client.notifications @@ -32,23 +38,39 @@ export type UseNotificationsOptions = { */ export const useNotifications = (options?: UseNotificationsOptions): Notification[] => { const { client } = useChatContext(); + const { displayFilter } = useNotificationConfigurationContext(); + const { applyDisplayFilter, fallbackPanel, filter, panel } = options ?? {}; const selector = useCallback( (state: NotificationManagerState) => { const notifications = state.notifications; - const panel = options?.panel; - const byPanel = panel - ? notifications.filter((notification) => - isNotificationForPanel(notification, panel, { - fallbackPanel: options?.fallbackPanel, - }), - ) - : notifications; - return { - notifications: options?.filter ? byPanel.filter(options.filter) : byPanel, + notifications: notifications.filter((notification) => { + if ( + panel && + !isNotificationForPanel(notification, panel, { + fallbackPanel, + }) + ) { + return false; + } + + if ( + applyDisplayFilter && + !displayFilter({ + fallbackPanel, + filter, + notification, + panel, + }) + ) { + return false; + } + + return filter ? filter(notification) : true; + }), }; }, - [options?.fallbackPanel, options?.filter, options?.panel], + [applyDisplayFilter, displayFilter, fallbackPanel, filter, panel], ); const { notifications } = useStateStore(client.notifications.store, selector); diff --git a/src/components/Notifications/index.ts b/src/components/Notifications/index.ts index 8e859c0f9..a832475a2 100644 --- a/src/components/Notifications/index.ts +++ b/src/components/Notifications/index.ts @@ -1,4 +1,5 @@ export * from './hooks'; +export * from './NotificationConfigurationContext'; export * from './Notification'; export * from './NotificationList'; export * from './notificationTarget'; diff --git a/src/components/Notifications/notificationTarget.ts b/src/components/Notifications/notificationTarget.ts index 4bb49f623..20b3cafea 100644 --- a/src/components/Notifications/notificationTarget.ts +++ b/src/components/Notifications/notificationTarget.ts @@ -5,6 +5,7 @@ const NOTIFICATION_TARGET_PANELS = [ 'thread', 'channel-list', 'thread-list', + 'modal', ] as const; /**