From bbd972a74ac48367433afd0bd38fbafd27e1b150 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 13:55:22 +0200 Subject: [PATCH 01/11] feat: add useNotificationApi --- .../__tests__/notificationOrigin.test.ts | 37 +++ .../__tests__/useNotificationApi.test.ts | 237 ++++++++++++++++++ src/components/Notifications/hooks/index.ts | 1 + .../Notifications/hooks/useNotificationApi.ts | 126 ++++++++++ .../Notifications/notificationTarget.ts | 26 +- 5 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts create mode 100644 src/components/Notifications/hooks/useNotificationApi.ts diff --git a/src/components/Notifications/__tests__/notificationOrigin.test.ts b/src/components/Notifications/__tests__/notificationOrigin.test.ts index eb768d700..6fbdd1324 100644 --- a/src/components/Notifications/__tests__/notificationOrigin.test.ts +++ b/src/components/Notifications/__tests__/notificationOrigin.test.ts @@ -1,5 +1,6 @@ import { getNotificationTargetPanel, + getNotificationTargetPanels, isNotificationForPanel, isNotificationTargetPanel, } from '../notificationTarget'; @@ -31,6 +32,19 @@ const taggedNotification = (tag: string) => tags: [tag], }) as Notification; +const multiTaggedNotification = (tags: string[]) => + ({ + createdAt: Date.now(), + id: 'n3', + message: 'test', + origin: { + context: {}, + emitter: 'test', + }, + severity: 'info', + tags, + }) as Notification; + describe('notificationOrigin helpers', () => { it('recognizes supported panel values', () => { expect(isNotificationTargetPanel('channel')).toBe(true); @@ -51,6 +65,14 @@ describe('notificationOrigin helpers', () => { ); }); + it('extracts all supported target panels from tags when present', () => { + expect( + getNotificationTargetPanels( + multiTaggedNotification(['target:thread', 'target:channel-list', 'ignored']), + ), + ).toEqual(['thread', 'channel-list']); + }); + it('falls back to channel panel when panel is missing', () => { expect(isNotificationForPanel(notification(), 'channel')).toBe(true); expect(isNotificationForPanel(notification(), 'thread')).toBe(false); @@ -60,4 +82,19 @@ describe('notificationOrigin helpers', () => { expect(isNotificationForPanel(notification('thread'), 'thread')).toBe(true); expect(isNotificationForPanel(notification('thread'), 'channel')).toBe(false); }); + + it('matches notification for any explicitly tagged target panel', () => { + const notificationWithMultipleTargets = multiTaggedNotification([ + 'target:thread', + 'target:channel-list', + ]); + + expect(isNotificationForPanel(notificationWithMultipleTargets, 'thread')).toBe(true); + expect(isNotificationForPanel(notificationWithMultipleTargets, 'channel-list')).toBe( + true, + ); + expect(isNotificationForPanel(notificationWithMultipleTargets, 'channel')).toBe( + false, + ); + }); }); diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts new file mode 100644 index 000000000..318bfc6fd --- /dev/null +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts @@ -0,0 +1,237 @@ +import { renderHook } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { useChatContext } from '../../../../context'; +import { useNotificationTarget } from '../useNotificationTarget'; +import { useNotificationApi } from '../useNotificationApi'; + +vi.mock('../../../../context', () => ({ + useChatContext: vi.fn(), +})); + +vi.mock('../useNotificationTarget', () => ({ + useNotificationTarget: vi.fn(), +})); + +const add = vi.fn(); + +const mockedUseChatContext = vi.mocked(useChatContext); +const mockedUseNotificationTarget = vi.mocked(useNotificationTarget); + +describe('useNotificationApi', () => { + beforeEach(() => { + mockedUseNotificationTarget.mockReturnValue('channel'); + mockedUseChatContext.mockReturnValue( + fromPartial({ + client: { + notifications: { + add, + }, + }, + }), + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('adds inferred target panel tag when targetPanels is not provided', () => { + mockedUseNotificationTarget.mockReturnValue('thread'); + + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'MessageComposer', + message: 'Send message request failed', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Send message request failed', + options: { tags: ['target:thread'] }, + origin: { emitter: 'MessageComposer' }, + }); + }); + + it('uses explicit target panels and ignores inferred panel tag', () => { + mockedUseNotificationTarget.mockReturnValue('channel'); + + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'ChannelListItemActionButtons', + message: 'Failed to update channel mute status', + severity: 'error', + tags: ['custom-tag'], + targetPanels: ['thread', 'channel-list'], + type: 'channelListItem:mute:failed', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Failed to update channel mute status', + options: { + severity: 'error', + tags: ['target:thread', 'target:channel-list', 'custom-tag'], + type: 'channelListItem:mute:failed', + }, + origin: { emitter: 'ChannelListItemActionButtons' }, + }); + }); + + it('allows passing targetPanels as an empty array to skip inferred panel tag', () => { + mockedUseNotificationTarget.mockReturnValue('thread-list'); + + 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('falls back to notifications.add for severities without dedicated helpers', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'NotificationPromptDialog', + message: 'Heads up', + severity: 'warning', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Heads up', + options: { severity: 'warning', tags: ['target:channel'] }, + origin: { emitter: 'NotificationPromptDialog' }, + }); + }); + + it('passes explicit type as-is', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'MessageComposer', + message: 'Edit message request failed', + severity: 'error', + type: 'api:message:edit:failed', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Edit message request failed', + options: { + severity: 'error', + tags: ['target:channel'], + type: 'api:message:edit:failed', + }, + origin: { emitter: 'MessageComposer' }, + }); + }); + + it('constructs type from incident when type is not provided', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'ShareLocationDialog', + incident: { + domain: 'api', + entity: 'location', + operation: 'share', + }, + message: 'Failed to share location', + severity: 'error', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Failed to share location', + options: { + severity: 'error', + tags: ['target:channel'], + type: 'api:location:share:failed', + }, + origin: { emitter: 'ShareLocationDialog' }, + }); + }); + + it('prefers explicit type over incident-derived type', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'ShareLocationDialog', + incident: { + domain: 'api', + entity: 'location', + operation: 'share', + }, + message: 'Failed to share location', + severity: 'error', + type: 'custom:type', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Failed to share location', + options: { + severity: 'error', + tags: ['target:channel'], + type: 'custom:type', + }, + origin: { emitter: 'ShareLocationDialog' }, + }); + }); + + it('uses incident.status when provided', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'ShareLocationDialog', + incident: { + domain: 'api', + entity: 'location', + operation: 'share', + status: 'blocked', + }, + message: 'Location sharing blocked', + severity: 'error', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Location sharing blocked', + options: { + severity: 'error', + tags: ['target:channel'], + type: 'api:location:share:blocked', + }, + origin: { emitter: 'ShareLocationDialog' }, + }); + }); + + it('falls back to severity as status for incident type when severity is non-error/success', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.addNotification({ + emitter: 'Uploader', + incident: { + domain: 'api', + entity: 'attachment', + operation: 'upload', + }, + message: 'Uploading attachment', + severity: 'loading', + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Uploading attachment', + options: { + severity: 'loading', + tags: ['target:channel'], + type: 'api:attachment:upload:loading', + }, + origin: { emitter: 'Uploader' }, + }); + }); +}); diff --git a/src/components/Notifications/hooks/index.ts b/src/components/Notifications/hooks/index.ts index cdd014977..53007ba3f 100644 --- a/src/components/Notifications/hooks/index.ts +++ b/src/components/Notifications/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useNotifications'; export * from './useNotificationTarget'; +export * from './useNotificationApi'; diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts new file mode 100644 index 000000000..1d8ca02a2 --- /dev/null +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -0,0 +1,126 @@ +import { useCallback } from 'react'; + +import type { NotificationAction, NotificationSeverity } from 'stream-chat'; + +import { useChatContext } from '../../../context'; +import { + addNotificationTargetTag, + getNotificationTargetTag, + type NotificationTargetPanel, +} from '../notificationTarget'; +import { useNotificationTarget } from './useNotificationTarget'; + +export type NotificationIncidentDescriptor = { + /** Where the incident happened (e.g. api, browser, validation, permission). */ + domain: string; + /** Entity being operated on (e.g. message, poll, location, attachment). */ + entity: string; + /** Attempted operation (e.g. send, end, share, upload). */ + operation: string; + /** Status of the operation (e.g. failed, success, blocked). */ + status?: string; +}; + +export type AddNotificationParams = { + /** Optional interactive actions rendered by the notification component. */ + actions?: NotificationAction[]; + /** Arbitrary context metadata stored in `origin.context`. */ + context?: Record; + /** Duration in milliseconds after which the notification auto-dismisses. */ + duration?: number; + /** Logical source emitting the notification (e.g. component or feature name). */ + emitter: string; + /** Underlying error object attached as `options.originalError`. */ + error?: Error; + /** Human-readable notification message. */ + message: string; + /** Notification severity visualized by the UI. */ + severity?: NotificationSeverity; + /** Additional tags appended to target panel tags. */ + tags?: string[]; + /** Explicit target panels; when provided, inferred panel is ignored. */ + targetPanels?: NotificationTargetPanel[]; + /** Structured descriptor of the incident this notification reports on. */ + incident?: NotificationIncidentDescriptor; + /** + * Optional machine-readable notification type identifier (domain:entity:operation:status). + * Used by notification consumers to route behavior, including translation lookup + * via notification-type registries. + * When omitted, `type` is generated from `incident` if `incident` is provided. + */ + type?: string; +}; + +const getTargetTags = ( + targetPanels: NotificationTargetPanel[] | undefined, + inferredPanel: NotificationTargetPanel | undefined, + tags: string[] | undefined, +) => { + if (targetPanels) { + return Array.from( + new Set([...targetPanels.map(getNotificationTargetTag), ...(tags ?? [])]), + ); + } + + return addNotificationTargetTag(inferredPanel, tags); +}; + +const getTypeFromIncident = ({ + incident, + severity, + type, +}: Pick) => { + if (type) return type; + if (!incident) return undefined; + + const status = + incident.status ?? + (severity === 'error' ? 'failed' : severity === 'success' ? 'success' : severity); + + return [incident.domain, incident.entity, incident.operation, status] + .filter((segment): segment is string => !!segment) + .join(':'); +}; + +export const useNotificationApi = () => { + const { client } = useChatContext(); + const inferredPanel = useNotificationTarget(); + + const addNotification = useCallback( + ({ + actions, + context, + duration, + emitter, + error, + incident, + message, + severity, + tags, + targetPanels, + type, + }: AddNotificationParams) => { + const notificationTags = getTargetTags(targetPanels, inferredPanel, tags); + const resolvedType = getTypeFromIncident({ incident, severity, type }); + const origin = context ? { context, emitter } : { emitter }; + + const options = { + ...(actions ? { actions } : {}), + ...(typeof duration === 'number' ? { duration } : {}), + ...(error ? { originalError: error } : {}), + ...(notificationTags.length > 0 ? { tags: notificationTags } : {}), + ...(severity ? { severity } : {}), + ...(resolvedType ? { type: resolvedType } : {}), + }; + + client.notifications.add({ + message, + options, + origin, + }); + }, + [client, inferredPanel], + ); + + return { addNotification }; +}; diff --git a/src/components/Notifications/notificationTarget.ts b/src/components/Notifications/notificationTarget.ts index ad20b9599..4bb49f623 100644 --- a/src/components/Notifications/notificationTarget.ts +++ b/src/components/Notifications/notificationTarget.ts @@ -31,6 +31,24 @@ export const getNotificationTargetPanel = ( return isNotificationTargetPanel(panel) ? panel : undefined; }; +export const getNotificationTargetPanels = ( + notification: Notification, +): NotificationTargetPanel[] => { + const targetPanels = (notification.tags ?? []) + .filter((tag) => tag.startsWith('target:')) + .map((tag) => tag.slice('target:'.length)) + .filter((value): value is NotificationTargetPanel => + isNotificationTargetPanel(value), + ); + + if (targetPanels.length > 0) { + return Array.from(new Set(targetPanels)); + } + + const panel = notification.origin.context?.panel; + return isNotificationTargetPanel(panel) ? [panel] : []; +}; + export const getNotificationTargetTag = (panel: NotificationTargetPanel) => `target:${panel}` as const; @@ -47,7 +65,11 @@ export const isNotificationForPanel = ( panel: NotificationTargetPanel, options?: { fallbackPanel?: NotificationTargetPanel }, ) => { - const fallbackPanel = options?.fallbackPanel ?? 'channel'; - const resolvedPanel = getNotificationTargetPanel(notification) ?? fallbackPanel; + const explicitTargetPanels = getNotificationTargetPanels(notification); + if (explicitTargetPanels.length > 0) { + return explicitTargetPanels.includes(panel); + } + + const resolvedPanel = options?.fallbackPanel ?? 'channel'; return resolvedPanel === panel; }; From 1987984269991855a392815f6b349dd0f0d611ea Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 16:07:49 +0200 Subject: [PATCH 02/11] feat: migrate to useNotificationApi.addNotification --- .../ActionsMenu/NotificationPromptDialog.tsx | 37 ++++---- .../Attachment/__tests__/Audio.test.tsx | 16 ++-- .../__tests__/VoiceRecording.test.tsx | 1 + .../AudioPlayback/WithAudioPlayback.tsx | 10 +- .../__tests__/WithAudioPlayback.test.tsx | 33 +++---- .../plugins/AudioPlayerNotificationsPlugin.ts | 26 ++--- .../AudioPlayerNotificationsPlugin.test.ts | 37 ++++---- src/components/Channel/Channel.tsx | 20 ++-- .../Channel/__tests__/Channel.test.tsx | 4 +- .../ChannelListItemActionButtons.defaults.tsx | 95 +++++++------------ .../Location/ShareLocationDialog.tsx | 31 +++--- .../__tests__/ShareLocationDialog.test.tsx | 5 +- .../AudioRecorderRecordingControls.tsx | 22 ++--- .../__tests__/AudioRecordingPreview.test.tsx | 5 +- src/components/Message/Message.tsx | 19 ++-- .../MessageAlsoSentInChannelIndicator.tsx | 22 ++--- .../Message/__tests__/Message.test.tsx | 24 ++--- .../MessageActions.defaults.tsx | 18 ++-- .../MessageComposer/hooks/useSubmitHandler.ts | 37 +++++--- .../Notifications/hooks/useNotificationApi.ts | 10 +- .../Poll/PollActions/EndPollAlert.tsx | 34 +++---- 21 files changed, 229 insertions(+), 277 deletions(-) diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx index 4e397af3e..3434393b2 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { Dispatch, PointerEvent as ReactPointerEvent, SetStateAction } from 'react'; import type { NotificationSeverity } from 'stream-chat'; import { - addNotificationTargetTag, IconArrowDown, IconArrowLeft, IconChevronRight, @@ -19,7 +18,7 @@ import { NumericInput, Prompt, TextInput, - useChatContext, + useNotificationApi, useDialogIsOpen, useDialogOnNearestManager, type NotificationListEnterFrom, @@ -307,7 +306,7 @@ export const NotificationPromptDialog = ({ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const chipIdRef = useRef(0); const shellRef = useRef(null); - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { dialog, dialogManager } = useDialogOnNearestManager({ id: notificationPromptDialogId, }); @@ -362,26 +361,22 @@ export const NotificationPromptDialog = ({ dialog.close(); }, [dialog]); - const addNotification = useCallback( + const publishNotification = useCallback( (notification: QueuedNotification) => { - client.notifications.add({ - message: notification.message, - origin: { - context: { - entryDirection: notification.entryDirection, - panel: notification.targetPanel, - }, - emitter: 'vite-preview/ActionsMenu', - }, - options: { - actions: buildNotificationActions(notification), - duration: notification.duration, - severity: notification.severity, - tags: addNotificationTargetTag(notification.targetPanel), + addNotification({ + actions: buildNotificationActions(notification), + context: { + entryDirection: notification.entryDirection, + panel: notification.targetPanel, }, + duration: notification.duration, + emitter: 'vite-preview/ActionsMenu', + message: notification.message, + severity: notification.severity, + targetPanels: [notification.targetPanel], }); }, - [client], + [addNotification], ); const queueCurrentDraft = useCallback(() => { @@ -406,9 +401,9 @@ export const NotificationPromptDialog = ({ }, [draft]); const registerQueuedNotifications = useCallback(() => { - queuedNotifications.forEach(addNotification); + queuedNotifications.forEach(publishNotification); closeDialog(); - }, [addNotification, closeDialog, queuedNotifications]); + }, [closeDialog, publishNotification, queuedNotifications]); const removeQueuedNotification = useCallback((id: string) => { setQueuedNotifications((current) => current.filter((item) => item.id !== id)); diff --git a/src/components/Attachment/__tests__/Audio.test.tsx b/src/components/Attachment/__tests__/Audio.test.tsx index 24ccffc5d..1b9603b32 100644 --- a/src/components/Attachment/__tests__/Audio.test.tsx +++ b/src/components/Attachment/__tests__/Audio.test.tsx @@ -12,6 +12,10 @@ import { prettifyFileSize } from '../../MessageComposer/hooks/utils'; import { WithAudioPlayback } from '../../AudioPlayback'; import { MessageProvider } from '../../../context'; +const { addNotificationSpy } = vi.hoisted(() => ({ + addNotificationSpy: vi.fn(), +})); + vi.mock('../../../context/ChatContext', () => ({ useChatContext: () => ({ client: mockClient }), })); @@ -19,12 +23,12 @@ vi.mock('../../../context/TranslationContext', () => ({ useTranslationContext: () => ({ t: (s) => tSpy(s) }), })); vi.mock('../../Notifications', () => ({ + useNotificationApi: () => ({ addNotification: addNotificationSpy }), useNotificationTarget: () => 'channel', })); -const addErrorSpy = vi.fn(); const mockClient = { - notifications: { addError: addErrorSpy }, + notifications: { add: addNotificationSpy }, }; const tSpy = (s) => s; @@ -75,8 +79,8 @@ const clickToPause = async () => { }; const expectAddErrorMessage = (message) => { - expect(addErrorSpy).toHaveBeenCalled(); - const hit = addErrorSpy.mock.calls.find((c) => c?.[0]?.message === message); + expect(addNotificationSpy).toHaveBeenCalled(); + const hit = addNotificationSpy.mock.calls.find((c) => c?.[0]?.message === message); expect(hit).toBeTruthy(); }; @@ -161,7 +165,7 @@ describe('Audio', () => { await clickToPause(); expect(playButton()).toBeInTheDocument(); - expect(addErrorSpy).not.toHaveBeenCalled(); + expect(addNotificationSpy).not.toHaveBeenCalled(); audioPausedMock.mockRestore(); }); @@ -188,7 +192,7 @@ describe('Audio', () => { expect(pauseButton()).not.toBeInTheDocument(); }); - expect(addErrorSpy).not.toHaveBeenCalled(); + expect(addNotificationSpy).not.toHaveBeenCalled(); vi.useRealTimers(); }); diff --git a/src/components/Attachment/__tests__/VoiceRecording.test.tsx b/src/components/Attachment/__tests__/VoiceRecording.test.tsx index 67f6d0662..b597d6cc1 100644 --- a/src/components/Attachment/__tests__/VoiceRecording.test.tsx +++ b/src/components/Attachment/__tests__/VoiceRecording.test.tsx @@ -14,6 +14,7 @@ import { ResizeObserverMock } from '../../../mock-builders/browser'; import { WithAudioPlayback } from '../../AudioPlayback'; vi.mock('../../Notifications', () => ({ + useNotificationApi: () => ({ addNotification: vi.fn() }), useNotificationTarget: () => 'channel', })); diff --git a/src/components/AudioPlayback/WithAudioPlayback.tsx b/src/components/AudioPlayback/WithAudioPlayback.tsx index b98212599..b2c4eeaef 100644 --- a/src/components/AudioPlayback/WithAudioPlayback.tsx +++ b/src/components/AudioPlayback/WithAudioPlayback.tsx @@ -4,8 +4,8 @@ import type { AudioPlayerOptions } from './AudioPlayer'; import type { AudioPlayerPoolState } from './AudioPlayerPool'; import { AudioPlayerPool } from './AudioPlayerPool'; import { audioPlayerNotificationsPluginFactory } from './plugins/AudioPlayerNotificationsPlugin'; -import { useNotificationTarget } from '../Notifications'; -import { useChatContext, useTranslationContext } from '../../context'; +import { useNotificationApi, useNotificationTarget } from '../Notifications'; +import { useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; export type WithAudioPlaybackProps = { @@ -67,7 +67,7 @@ export const useAudioPlayer = ({ title, waveformData, }: UseAudioPlayerProps) => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const panel = useNotificationTarget(); const { t } = useTranslationContext(); const { audioPlayers } = useContext(AudioPlayerContext); @@ -94,7 +94,7 @@ export const useAudioPlayer = ({ * and instead provide plugin that takes care of translated notifications. */ const notificationsPlugin = audioPlayerNotificationsPluginFactory({ - client, + addNotification, panel, t, }); @@ -102,7 +102,7 @@ export const useAudioPlayer = ({ ...currentPlugins.filter((plugin) => plugin.id !== notificationsPlugin.id), notificationsPlugin, ]); - }, [audioPlayer, client, panel, t]); + }, [addNotification, audioPlayer, panel, t]); return audioPlayer; }; diff --git a/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx index 00922b662..5b80b0088 100644 --- a/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx +++ b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx @@ -5,34 +5,31 @@ import { act, cleanup, render } from '@testing-library/react'; import { useAudioPlayer, WithAudioPlayback } from '../WithAudioPlayback'; import type { AudioPlayer } from '../AudioPlayer'; +const { mockAddNotification } = vi.hoisted(() => ({ + mockAddNotification: vi.fn(), +})); + // mock context used by WithAudioPlayback vi.mock('../../../context', () => { - const mockAddError = vi.fn(); - const mockClient = { notifications: { addError: mockAddError } }; const t = (s: string) => s; return { __esModule: true, - mockAddError, - useChatContext: () => ({ client: mockClient }), useTranslationContext: () => ({ t }), - // export spy so tests can assert on it }; }); // mock useNotificationTarget (called by useAudioPlayer) vi.mock('../../Notifications', async (importOriginal: any) => ({ ...(await importOriginal()), + useNotificationApi: () => ({ + addNotification: mockAddNotification, + }), useNotificationTarget: () => 'channel', })); // make throttle a no-op (so seek/time-related stuff runs synchronously) vi.mock('lodash.throttle', () => ({ default: (fn: any) => fn })); -// ------------------ imports FROM mocks ------------------ - -// @ts-expect-error mockAddError is a custom export from the vi.mock factory above -import { mockAddError as addErrorSpy } from '../../../context'; - // silence console.error in tests but capture calls for assertions const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -67,7 +64,7 @@ afterEach(() => { cleanup(); vi.resetAllMocks(); createdAudios.length = 0; - // addErrorSpy.mockReset(); + // mockAddNotification.mockReset(); // defaultRegisterSpy.mockClear(); }); @@ -277,11 +274,11 @@ describe('WithAudioPlayback + useAudioPlayer', () => { player.registerError({ errCode: 'failed-to-start' }); }); - expect(addErrorSpy).toHaveBeenCalled(); - const call = addErrorSpy.mock.calls[0][0]; + expect(mockAddNotification).toHaveBeenCalled(); + const call = mockAddNotification.mock.calls[0][0]; expect(call.message).toBe('Failed to play the recording'); - expect(call.options.type).toBe('browser:audio:playback:error'); - expect(call.origin.emitter).toBe('AudioPlayer'); + expect(call.type).toBe('browser:audio:playback:error'); + expect(call.emitter).toBe('AudioPlayer'); }); it('registerError mapping: not-playable / seek-not-supported', () => { @@ -299,7 +296,7 @@ describe('WithAudioPlayback + useAudioPlayer', () => { act(() => { player.registerError({ errCode: 'not-playable' }); }); - let call = addErrorSpy.mock.calls.pop()[0]; + let call = mockAddNotification.mock.calls.pop()[0]; expect(call.message).toBe( 'Recording format is not supported and cannot be reproduced', ); @@ -307,7 +304,7 @@ describe('WithAudioPlayback + useAudioPlayer', () => { act(() => { player.registerError({ errCode: 'seek-not-supported' }); }); - call = addErrorSpy.mock.calls.pop()[0]; + call = mockAddNotification.mock.calls.pop()[0]; expect(call.message).toBe('Cannot seek in the recording'); }); @@ -327,7 +324,7 @@ describe('WithAudioPlayback + useAudioPlayer', () => { player.registerError({ error: new Error('Boom!') }); }); - const call = addErrorSpy.mock.calls[0][0]; + const call = mockAddNotification.mock.calls[0][0]; expect(call.message).toBe('Boom!'); }); diff --git a/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts b/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts index abd408ef2..3d6fc1b8a 100644 --- a/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts +++ b/src/components/AudioPlayback/plugins/AudioPlayerNotificationsPlugin.ts @@ -1,18 +1,15 @@ import type { AudioPlayerPlugin } from './AudioPlayerPlugin'; import { type AudioPlayerErrorCode } from '../AudioPlayer'; -import type { StreamChat } from 'stream-chat'; import type { TFunction } from 'i18next'; -import { - addNotificationTargetTag, - type NotificationTargetPanel, -} from '../../Notifications/notificationTarget'; +import type { AddNotification } from '../../Notifications/hooks/useNotificationApi'; +import type { NotificationTargetPanel } from '../../Notifications/notificationTarget'; export const audioPlayerNotificationsPluginFactory = ({ - client, + addNotification, panel = 'channel', t, }: { - client: StreamChat; + addNotification: AddNotification; panel?: NotificationTargetPanel; t: TFunction; }): AudioPlayerPlugin => { @@ -32,16 +29,13 @@ export const audioPlayerNotificationsPluginFactory = ({ e ?? new Error(t('Error reproducing the recording')); - client?.notifications.addError({ + addNotification({ + emitter: 'AudioPlayer', + error, message: error.message, - options: { - originalError: error, - tags: addNotificationTargetTag(panel), - type: 'browser:audio:playback:error', - }, - origin: { - emitter: 'AudioPlayer', - }, + severity: 'error', + targetPanels: [panel], + type: 'browser:audio:playback:error', }); }, }; diff --git a/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts b/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts index f0e2877d7..2677551bd 100644 --- a/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts +++ b/src/components/AudioPlayback/plugins/__tests__/AudioPlayerNotificationsPlugin.test.ts @@ -1,71 +1,70 @@ import { fromPartial } from '@total-typescript/shoehorn'; -import type { StreamChat } from 'stream-chat'; import type { TFunction } from 'i18next'; import { audioPlayerNotificationsPluginFactory } from '../AudioPlayerNotificationsPlugin'; describe('audioPlayerNotificationsPluginFactory', () => { const t: TFunction = ((s: string) => s) as TFunction; - const makeClient = () => { - const addError = vi.fn(); + const makeNotifier = () => { + const addNotification = vi.fn(); return { - addError, - client: { notifications: { addError } }, + addNotification, }; }; it('reports mapped error messages for known errCodes', () => { - const { addError, client } = makeClient(); + const { addNotification } = makeNotifier(); const plugin = audioPlayerNotificationsPluginFactory({ - client: fromPartial(client), + addNotification, t, }); // simulate failed-to-start plugin.onError?.({ errCode: 'failed-to-start', player: fromPartial({}) }); - expect(addError).toHaveBeenCalledTimes(1); - let call = addError.mock.calls[0][0]; + expect(addNotification).toHaveBeenCalledTimes(1); + let call = addNotification.mock.calls[0][0]; expect(call.message).toBe('Failed to play the recording'); - expect(call.options.type).toBe('browser:audio:playback:error'); - expect(call.origin.emitter).toBe('AudioPlayer'); + expect(call.type).toBe('browser:audio:playback:error'); + expect(call.emitter).toBe('AudioPlayer'); + expect(call.severity).toBe('error'); // simulate not-playable plugin.onError?.({ errCode: 'not-playable', player: fromPartial({}) }); - call = addError.mock.calls[1][0]; + call = addNotification.mock.calls[1][0]; expect(call.message).toBe( 'Recording format is not supported and cannot be reproduced', ); // simulate seek-not-supported plugin.onError?.({ errCode: 'seek-not-supported', player: fromPartial({}) }); - call = addError.mock.calls[2][0]; + call = addNotification.mock.calls[2][0]; expect(call.message).toBe('Cannot seek in the recording'); }); it('falls back to provided Error if no errCode', () => { - const { addError, client } = makeClient(); + const { addNotification } = makeNotifier(); const plugin = audioPlayerNotificationsPluginFactory({ - client: fromPartial(client), + addNotification, t, }); plugin.onError?.({ error: new Error('X-Error'), player: fromPartial({}) }); - const call = addError.mock.calls[0][0]; + const call = addNotification.mock.calls[0][0]; expect(call.message).toBe('X-Error'); }); it('falls back to generic message if no errCode and no Error', () => { - const { addError, client } = makeClient(); + const { addNotification } = makeNotifier(); const plugin = audioPlayerNotificationsPluginFactory({ - client: fromPartial(client), + addNotification, t, }); plugin.onError?.({ player: fromPartial({}) }); - const call = addError.mock.calls[0][0]; + const call = addNotification.mock.calls[0][0]; expect(call.message).toBe('Error reproducing the recording'); }); }); diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 6729ab62d..e962dfd2e 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -44,7 +44,7 @@ import { LoadingChannel as DefaultLoadingIndicator, } from '../Loading'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; -import { addNotificationTargetTag } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import type { ChannelActionContextValue, MarkReadWrapperOptions } from '../../context'; import { @@ -242,6 +242,7 @@ const ChannelInner = ( const { client, customClasses, latestMessageDatesByChannels, mutes, searchController } = useChatContext('Channel'); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext('Channel'); const chatContainerClass = getChatContainerClass(customClasses?.chatContainer); const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); @@ -568,18 +569,15 @@ const ChannelInner = ( /** MESSAGE */ const notifyJumpToFirstUnreadError = useCallback(() => { - client.notifications.addError({ + addNotification({ + context: { feature: 'jumpToFirstUnread' }, + emitter: 'Channel', message: t('Failed to jump to the first unread message'), - options: { - tags: addNotificationTargetTag('channel'), - type: 'channel:jumpToFirstUnread:failed', - }, - origin: { - context: { feature: 'jumpToFirstUnread' }, - emitter: 'Channel', - }, + severity: 'error', + targetPanels: ['channel'], + type: 'channel:jumpToFirstUnread:failed', }); - }, [client, t]); + }, [addNotification, t]); // eslint-disable-next-line react-hooks/exhaustive-deps const loadMoreFinished = useCallback( diff --git a/src/components/Channel/__tests__/Channel.test.tsx b/src/components/Channel/__tests__/Channel.test.tsx index 09a7ddb81..ce720891d 100644 --- a/src/components/Channel/__tests__/Channel.test.tsx +++ b/src/components/Channel/__tests__/Channel.test.tsx @@ -1339,7 +1339,7 @@ describe('Channel', () => { } } - const addErrorSpy = vi.spyOn(chatClient.notifications, 'addError'); + const addErrorSpy = vi.spyOn(chatClient.notifications, 'add'); let hasJumped: boolean; let highlightedMessageId: string; let channelUnreadUiStateAfterJump: ChannelUnreadUiState | undefined; @@ -1558,7 +1558,7 @@ describe('Channel', () => { ], customUser: user, }); - const addErrorSpy = vi.spyOn(chatClient.notifications, 'addError'); + const addErrorSpy = vi.spyOn(chatClient.notifications, 'add'); let hasJumped: boolean; let hasMoreMessages: boolean; let highlightedMessageId: string; diff --git a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx index b563b8fd4..80fb8d77e 100644 --- a/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx +++ b/src/components/ChannelListItem/ChannelListItemActionButtons.defaults.tsx @@ -20,16 +20,15 @@ import { } from '../Icons'; import { useIsChannelMuted } from './hooks/useIsChannelMuted'; import { ContextMenuButton, useDialogIsOpen, useDialogOnNearestManager } from '../Dialog'; -import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import { ChannelListItemActionButtons } from './ChannelListItemActionButtons'; const useMuteActionButtonBehavior = () => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { channel } = useChannelListItemContext(); const { t } = useTranslationContext(); const { muted: isMuted } = useIsChannelMuted(channel); const [inProgress, setInProgress] = useState(false); - const panel = useNotificationTarget(); return { 'aria-pressed': isMuted, @@ -44,17 +43,12 @@ const useMuteActionButtonBehavior = () => { await channel.mute(); } } catch (error) { - client.notifications.addError({ + addNotification({ + emitter: ChannelListItemActionButtons.name, + error: error instanceof Error ? error : new Error('An unknown error occurred'), message: t('Failed to update channel mute status'), - options: { - originalError: - error instanceof Error ? error : new Error('An unknown error occurred'), - tags: addNotificationTargetTag(panel), - type: 'channelListItem:mute:failed', - }, - origin: { - emitter: ChannelListItemActionButtons.name, - }, + severity: 'error', + type: 'channelListItem:mute:failed', }); } finally { setInProgress(false); @@ -66,11 +60,10 @@ const useMuteActionButtonBehavior = () => { const useArchiveActionButtonBehavior = () => { const { channel } = useChannelListItemContext(); - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const membership = useChannelMembershipState(channel); const { t } = useTranslationContext(); const [inProgress, setInProgress] = useState(false); - const panel = useNotificationTarget(); return { 'aria-pressed': typeof membership.archived_at === 'string', @@ -85,17 +78,12 @@ const useArchiveActionButtonBehavior = () => { await channel.archive(); } } catch (error) { - client.notifications.addError({ + addNotification({ + emitter: ChannelListItemActionButtons.name, + error: error instanceof Error ? error : new Error('An unknown error occurred'), message: t('Failed to update channel archive status'), - options: { - originalError: - error instanceof Error ? error : new Error('An unknown error occurred'), - tags: addNotificationTargetTag(panel), - type: 'channelListItem:archive:failed', - }, - origin: { - emitter: ChannelListItemActionButtons.name, - }, + severity: 'error', + type: 'channelListItem:archive:failed', }); } finally { setInProgress(false); @@ -226,11 +214,11 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ { Component() { const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { channel } = useChannelListItemContext(); const [inProgress, setInProgress] = useState(false); const members = useChannelMembersState(channel); - const panel = useNotificationTarget(); const isUserBanned = Object.values(members || {}).some( (member) => member.user?.id !== client.userID && member.banned, ); @@ -258,19 +246,13 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ await channel.banUser(otherUserId, {}); } } catch (error) { - client.notifications.addError({ + addNotification({ + emitter: ChannelListItemActionButtons.name, + error: + error instanceof Error ? error : new Error('An unknown error occurred'), message: t('Failed to block user'), - options: { - originalError: - error instanceof Error - ? error - : new Error('An unknown error occurred'), - tags: addNotificationTargetTag(panel), - type: 'channelListItem:ban:failed', - }, - origin: { - emitter: ChannelListItemActionButtons.name, - }, + severity: 'error', + type: 'channelListItem:ban:failed', }); } finally { setInProgress(false); @@ -287,7 +269,7 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ { Component() { const { t } = useTranslationContext(); - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { channel } = useChannelListItemContext(); const membership = useChannelMembershipState(channel); const dialogId = ChannelListItemActionButtons.getDialogId( @@ -296,7 +278,6 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ ); const { dialog } = useDialogOnNearestManager({ id: dialogId }); const [inProgress, setInProgress] = useState(false); - const panel = useNotificationTarget(); const title = membership.pinned_at ? t('Unpin') : t('Pin'); @@ -317,16 +298,12 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ } } catch (e) { error = e instanceof Error ? e : new Error('An unknown error occurred'); - client.notifications.addError({ + addNotification({ + emitter: ChannelListItemActionButtons.name, + error, message: t('Failed to update channel pinned status'), - options: { - originalError: error, - tags: addNotificationTargetTag(panel), - type: 'channelListItem:pin:failed', - }, - origin: { - emitter: ChannelListItemActionButtons.name, - }, + severity: 'error', + type: 'channelListItem:pin:failed', }); } finally { if (!error) dialog?.close(); @@ -347,8 +324,8 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ const { t } = useTranslationContext(); const { channel } = useChannelListItemContext(); const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const [inProgress, setInProgress] = useState(false); - const panel = useNotificationTarget(); const title = t('Leave Channel'); @@ -364,19 +341,13 @@ export const defaultChannelActionSet: ChannelActionItem[] = [ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await channel.removeMembers([client.userID!]); } catch (error) { - client.notifications.addError({ + addNotification({ + emitter: ChannelListItemActionButtons.name, + error: + error instanceof Error ? error : new Error('An unknown error occurred'), message: t('Failed to leave channel'), - options: { - originalError: - error instanceof Error - ? error - : new Error('An unknown error occurred'), - tags: addNotificationTargetTag(panel), - type: 'channelListItem:leave:failed', - }, - origin: { - emitter: ChannelListItemActionButtons.name, - }, + severity: 'error', + type: 'channelListItem:leave:failed', }); } finally { setInProgress(false); diff --git a/src/components/Location/ShareLocationDialog.tsx b/src/components/Location/ShareLocationDialog.tsx index ee5f823b2..138442e2c 100644 --- a/src/components/Location/ShareLocationDialog.tsx +++ b/src/components/Location/ShareLocationDialog.tsx @@ -5,7 +5,7 @@ import React, { useMemo, useState, } from 'react'; -import { useChatContext, useTranslationContext } from '../../context'; +import { useTranslationContext } from '../../context'; import { ContextMenuBody, ContextMenuButton, ContextMenuRoot, Prompt } from '../Dialog'; import { Dropdown, @@ -15,7 +15,7 @@ import { import { IconChevronDown } from '../Icons'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { SwitchField } from '../Form/SwitchField'; -import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import type { Coords } from 'stream-chat'; import { Button } from '../Button'; import clsx from 'clsx'; @@ -65,9 +65,8 @@ export const ShareLocationDialog = ({ GeolocationMap = DefaultGeolocationMap, shareDurations = DEFAULT_SHARE_LOCATION_DURATIONS, }: ShareLocationDialogProps) => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); - const panel = useNotificationTarget(); const messageComposer = useMessageComposerController(); const [durations, setDurations] = useState([]); const [selectedDuration, setSelectedDuration] = useState(undefined); @@ -243,14 +242,12 @@ export const ShareLocationDialog = ({ try { coords = (await getPosition()).coords; } catch (e) { - client.notifications.addError({ + addNotification({ + emitter: 'ShareLocationDialog', + error: e instanceof Error ? e : undefined, message: t('Failed to retrieve location'), - options: { - originalError: e instanceof Error ? e : undefined, - tags: addNotificationTargetTag(panel), - type: 'browser:location:get:failed', - }, - origin: { emitter: 'ShareLocationDialog' }, + severity: 'error', + type: 'browser:location:get:failed', }); return; } @@ -263,14 +260,12 @@ export const ShareLocationDialog = ({ try { await messageComposer.sendLocation(); } catch (err) { - client.notifications.addError({ + addNotification({ + emitter: 'ShareLocationDialog', + error: err instanceof Error ? err : undefined, message: t('Failed to share location'), - options: { - originalError: err instanceof Error ? err : undefined, - tags: addNotificationTargetTag(panel), - type: 'api:location:share:failed', - }, - origin: { emitter: 'ShareLocationDialog' }, + severity: 'error', + type: 'api:location:share:failed', }); return; } diff --git a/src/components/Location/__tests__/ShareLocationDialog.test.tsx b/src/components/Location/__tests__/ShareLocationDialog.test.tsx index 22613bad5..7255ec87d 100644 --- a/src/components/Location/__tests__/ShareLocationDialog.test.tsx +++ b/src/components/Location/__tests__/ShareLocationDialog.test.tsx @@ -28,8 +28,9 @@ vi.mock('../../MessageComposer/hooks/useMessageComposerController', () => ({ }), })); -vi.mock('../../Notifications', () => ({ - addNotificationTargetTag: vi.fn((panel) => ({ panel })), +vi.mock('../../Notifications', async (importOriginal) => ({ + ...((await importOriginal()) as object), + useNotificationApi: vi.fn().mockReturnValue({ addNotification: vi.fn() }), useNotificationTarget: vi.fn().mockReturnValue('channel'), })); diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx index 332ea6604..3563d3d77 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx @@ -1,14 +1,10 @@ import { CheckSignIcon, LoadingIndicatorIcon } from '../../MessageComposer/icons'; import { IconDelete, IconPauseFill, IconVoice } from '../../Icons'; import React from 'react'; -import { - useChatContext, - useMessageComposerContext, - useTranslationContext, -} from '../../../context'; +import { useMessageComposerContext, useTranslationContext } from '../../../context'; import { isRecording } from './recordingStateIdentity'; import { Button } from '../../Button'; -import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications'; +import { useNotificationApi } from '../../Notifications'; const ToggleRecordingButton = () => { const { @@ -32,13 +28,11 @@ const ToggleRecordingButton = () => { }; export const AudioRecorderRecordingControls = () => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { recordingController: { completeRecording, recorder, recording, recordingState }, } = useMessageComposerContext(); - const panel = useNotificationTarget(); - const isUploadingFile = recording?.localMetadata?.uploadState === 'uploading'; if (!recorder) return null; @@ -54,13 +48,11 @@ export const AudioRecorderRecordingControls = () => { disabled={isUploadingFile} onClick={() => { recorder.cancel(); - client.notifications.addInfo({ + addNotification({ + emitter: 'AudioRecorder', message: t('Voice message deleted'), - options: { - tags: addNotificationTargetTag(panel), - type: 'audioRecording:cancel:success', - }, - origin: { emitter: 'AudioRecorder' }, + severity: 'info', + type: 'audioRecording:cancel:success', }); }} size='sm' diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx index 32714c04c..5695951e4 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.tsx @@ -28,8 +28,8 @@ const defaultProps = { waveformData: [0.1, 0.2, 0.3, 0.4, 0.5], }; -const addErrorSpy = vi.fn(); -const mockClient = { notifications: { addError: addErrorSpy } }; +const addNotificationSpy = vi.fn(); +const mockClient = { notifications: { addError: addNotificationSpy } }; const tSpy = (s) => s; vi.mock('../../../../context', () => ({ @@ -39,6 +39,7 @@ vi.mock('../../../../context', () => ({ vi.mock('../../../Notifications', async (importOriginal) => ({ ...(await importOriginal()), + useNotificationApi: () => ({ addNotification: addNotificationSpy }), useNotificationTarget: () => 'channel', })); diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 020b30828..c8bd7680c 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -25,7 +25,7 @@ import { useComponentContext, useMessageTranslationViewContext, } from '../../context'; -import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import { MessageUI as DefaultMessageUI } from './MessageUI'; @@ -213,21 +213,18 @@ export const Message = (props: MessageProps) => { sortReactions, } = props; - const { client } = useChatContext('Message'); + const { addNotification } = useNotificationApi(); const { highlightedMessageId, mutes } = useChannelStateContext('Message'); - const panel = useNotificationTarget(); const notify = useCallback( (text: string, type: 'success' | 'error') => { - const origin = { emitter: 'Message' }; - const options = { tags: addNotificationTargetTag(panel) }; - if (type === 'error') { - client.notifications.addError({ message: text, options, origin }); - } else { - client.notifications.addSuccess({ message: text, options, origin }); - } + addNotification({ + emitter: 'Message', + message: text, + severity: type, + }); }, - [client, panel], + [addNotification], ); const handleAction = useActionHandler(message); diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx index 8d722f4f7..29d9fe48c 100644 --- a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx +++ b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx @@ -1,11 +1,10 @@ import React, { useEffect, useRef } from 'react'; import { IconArrowUpRight } from '../Icons'; -import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import { useChannelActionContext, useChannelStateContext, - useChatContext, useMessageContext, useTranslationContext, } from '../../context'; @@ -15,12 +14,11 @@ import { formatMessage, type LocalMessage } from 'stream-chat'; * Indicator shown when the message was also sent to the main channel (show_in_channel === true). */ export const MessageAlsoSentInChannelIndicator = () => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { channel } = useChannelStateContext(); const { jumpToMessage, openThread } = useChannelActionContext(); const { message, threadList } = useMessageContext('MessageAlsoSentInChannelIndicator'); - const panel = useNotificationTarget(); const targetMessageRef = useRef(undefined); const queryParent = () => @@ -34,17 +32,13 @@ export const MessageAlsoSentInChannelIndicator = () => { targetMessageRef.current = formatMessage(results[0].message); }) .catch((error: Error) => { - client.notifications.addError({ + addNotification({ + context: { threadReply: message }, + emitter: 'MessageIsThreadReplyInChannelButtonIndicator', + error, message: t('Thread has not been found'), - options: { - originalError: error, - tags: addNotificationTargetTag(panel), - type: 'api:reply:search:failed', - }, - origin: { - context: { threadReply: message }, - emitter: 'MessageIsThreadReplyInChannelButtonIndicator', - }, + severity: 'error', + type: 'api:reply:search:failed', }); }); diff --git a/src/components/Message/__tests__/Message.test.tsx b/src/components/Message/__tests__/Message.test.tsx index dbf8c6963..9d9b6af85 100644 --- a/src/components/Message/__tests__/Message.test.tsx +++ b/src/components/Message/__tests__/Message.test.tsx @@ -404,7 +404,7 @@ describe(' component', () => { it('should allow to mute a user and notify with custom success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const muteUser = vi.fn(() => Promise.resolve()); const userMutedNotification = 'User muted!'; const getMuteUserSuccessNotification = vi.fn(() => userMutedNotification); @@ -435,7 +435,7 @@ describe(' component', () => { const message = generateMessage({ user: bob }); const defaultSuccessMessage = '{{ user }} has been muted'; const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const muteUser = vi.fn(() => Promise.resolve()); // @ts-expect-error - mock implementation has simplified signature vi.spyOn(client, 'muteUser').mockImplementation(muteUser); @@ -463,7 +463,7 @@ describe(' component', () => { it('should allow to mute a user and notify with custom error message when muting a user fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const muteUser = vi.fn(() => Promise.reject()); const userMutedFailNotification = 'User mute failed!'; const getMuteUserErrorNotification = vi.fn(() => userMutedFailNotification); @@ -493,7 +493,7 @@ describe(' component', () => { it('should allow to mute a user and notify with default error message when muting a user fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const muteUser = vi.fn(() => Promise.reject()); const defaultFailNotification = 'Error muting a user ...'; vi.spyOn(client, 'muteUser').mockImplementation(muteUser); @@ -521,7 +521,7 @@ describe(' component', () => { it('should allow to unmute a user and notify with custom success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const unmuteUser = vi.fn(() => Promise.resolve()); const userUnmutedNotification = 'User unmuted!'; const getMuteUserSuccessNotification = vi.fn(() => userUnmutedNotification); @@ -552,7 +552,7 @@ describe(' component', () => { it('should allow to unmute a user and notify with default success notification when it is successful', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const unmuteUser = vi.fn(() => Promise.resolve()); const defaultSuccessNotification = '{{ user }} has been unmuted'; // @ts-expect-error - mock implementation has simplified signature @@ -581,7 +581,7 @@ describe(' component', () => { it('should allow to unmute a user and notify with custom error message when it fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const unmuteUser = vi.fn(() => Promise.reject()); const userMutedFailNotification = 'User muted failed!'; const getMuteUserErrorNotification = vi.fn(() => userMutedFailNotification); @@ -611,7 +611,7 @@ describe(' component', () => { it('should allow to unmute a user and notify with default error message when it fails', async () => { const message = generateMessage({ user: bob }); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const unmuteUser = vi.fn(() => Promise.reject()); const defaultFailNotification = 'Error unmuting a user ...'; vi.spyOn(client, 'unmuteUser').mockImplementation(unmuteUser); @@ -804,7 +804,7 @@ describe(' component', () => { it('should allow to flag a message and notify with custom success notification when it is successful', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const flagMessage = vi.fn(() => Promise.resolve()); // @ts-expect-error - mock implementation has simplified signature vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage); @@ -834,7 +834,7 @@ describe(' component', () => { it('should allow to flag a message and notify with default success notification when it is successful', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess'); + const addSuccessSpy = vi.spyOn(client.notifications, 'add'); const flagMessage = vi.fn(() => Promise.resolve()); // @ts-expect-error - mock implementation has simplified signature vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage); @@ -862,7 +862,7 @@ describe(' component', () => { it('should allow to flag a message and notify with custom error message when it fails', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const flagMessage = vi.fn(() => Promise.reject()); vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage); const messageFlagFailedNotification = 'Message flagged failed!'; @@ -891,7 +891,7 @@ describe(' component', () => { it('should allow to flag a user and notify with default error message when it fails', async () => { const message = generateMessage(); const client = await getTestClientWithUser(alice); - const addErrorSpy = vi.spyOn(client.notifications, 'addError'); + const addErrorSpy = vi.spyOn(client.notifications, 'add'); const flagMessage = vi.fn(() => Promise.reject()); vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage); const defaultFlagMessageFailedNotification = 'Error adding flag'; diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx index 53354e974..6e8d7d09e 100644 --- a/src/components/MessageActions/MessageActions.defaults.tsx +++ b/src/components/MessageActions/MessageActions.defaults.tsx @@ -28,7 +28,7 @@ import { import { isUserMuted } from '../Message/utils'; import { useMessageComposerController } from '../MessageComposer/hooks/useMessageComposerController'; import { savePreEditSnapshot } from '../MessageComposer/preEditSnapshot'; -import { addNotificationTargetTag, useNotificationTarget } from '../Notifications'; +import { useNotificationApi } from '../Notifications'; import { useMessageReminder } from '../Message/hooks/useMessageReminder'; import { ReactionSelectorWithButton } from '../Reactions/ReactionSelectorWithButton'; import { @@ -305,10 +305,9 @@ const DefaultMessageActionComponents = { }, Delete() { const { closeMenu } = useContextMenuContext(); - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { Modal = GlobalModal } = useComponentContext(); const { handleDelete } = useMessageContext(); - const panel = useNotificationTarget(); const { t } = useTranslationContext(); const [openModal, setOpenModal] = useState(false); @@ -334,12 +333,15 @@ const DefaultMessageActionComponents = { onDelete={async () => { try { await handleDelete(); - client.notifications.addSuccess({ - message: t('Message deleted'), - options: { - tags: addNotificationTargetTag(panel), + addNotification({ + emitter: 'MessageActions', + incident: { + domain: 'message', + entity: 'delete', + operation: 'delete', }, - origin: { emitter: 'MessageActions' }, + message: t('Message deleted'), + severity: 'success', }); } catch { // Error notification is handled by useDeleteHandler. diff --git a/src/components/MessageComposer/hooks/useSubmitHandler.ts b/src/components/MessageComposer/hooks/useSubmitHandler.ts index bd4de8da7..d6cb4a87c 100644 --- a/src/components/MessageComposer/hooks/useSubmitHandler.ts +++ b/src/components/MessageComposer/hooks/useSubmitHandler.ts @@ -1,10 +1,9 @@ import { useCallback } from 'react'; import { MessageComposer } from 'stream-chat'; -import { useChatContext } from '../../../context/ChatContext'; import { useMessageComposerController } from './useMessageComposerController'; import { useChannelActionContext } from '../../../context/ChannelActionContext'; import { useTranslationContext } from '../../../context/TranslationContext'; -import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications'; +import { useNotificationApi } from '../../Notifications'; import { discardPreEditSnapshot } from '../preEditSnapshot'; import type { MessageComposerProps } from '../MessageComposer'; @@ -31,10 +30,9 @@ const takeStateSnapshot = (messageComposer: MessageComposer) => { export const useSubmitHandler = (props: MessageComposerProps) => { const { overrideSubmitHandler } = props; - const { client } = useChatContext('useSubmitHandler'); + const { addNotification } = useNotificationApi(); const { editMessage, sendMessage } = useChannelActionContext('useSubmitHandler'); const { t } = useTranslationContext('useSubmitHandler'); - const panel = useNotificationTarget(); const messageComposer = useMessageComposerController(); const handleSubmit = useCallback( @@ -51,10 +49,15 @@ export const useSubmitHandler = (props: MessageComposerProps) => { discardPreEditSnapshot(messageComposer); messageComposer.clear(); } catch (err) { - client.notifications.addError({ + addNotification({ + emitter: 'MessageComposer', + incident: { + domain: 'api', + entity: 'message', + operation: 'edit', + }, message: t('Edit message request failed'), - options: { tags: addNotificationTargetTag(panel) }, - origin: { emitter: 'MessageComposer' }, + severity: 'error', }); } } else { @@ -86,15 +89,27 @@ export const useSubmitHandler = (props: MessageComposerProps) => { await messageComposer.channel.stopTyping(); } catch (err) { restoreComposerStateSnapshot(); - client.notifications.addError({ + addNotification({ + emitter: 'MessageComposer', + incident: { + domain: 'api', + entity: 'message', + operation: 'send', + }, message: t('Send message request failed'), - options: { tags: addNotificationTargetTag(panel) }, - origin: { emitter: 'MessageComposer' }, + severity: 'error', }); } } }, - [client, editMessage, messageComposer, overrideSubmitHandler, panel, sendMessage, t], + [ + addNotification, + editMessage, + messageComposer, + overrideSubmitHandler, + sendMessage, + t, + ], ); return { handleSubmit }; diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts index 1d8ca02a2..5282277a1 100644 --- a/src/components/Notifications/hooks/useNotificationApi.ts +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -51,6 +51,12 @@ export type AddNotificationParams = { type?: string; }; +export type AddNotification = (params: AddNotificationParams) => void; + +export type NotificationApi = { + addNotification: AddNotification; +}; + const getTargetTags = ( targetPanels: NotificationTargetPanel[] | undefined, inferredPanel: NotificationTargetPanel | undefined, @@ -82,11 +88,11 @@ const getTypeFromIncident = ({ .join(':'); }; -export const useNotificationApi = () => { +export const useNotificationApi = (): NotificationApi => { const { client } = useChatContext(); const inferredPanel = useNotificationTarget(); - const addNotification = useCallback( + const addNotification: AddNotification = useCallback( ({ actions, context, diff --git a/src/components/Poll/PollActions/EndPollAlert.tsx b/src/components/Poll/PollActions/EndPollAlert.tsx index 243dc39ee..c8bc73b48 100644 --- a/src/components/Poll/PollActions/EndPollAlert.tsx +++ b/src/components/Poll/PollActions/EndPollAlert.tsx @@ -1,20 +1,14 @@ import React from 'react'; import { Alert } from '../../Dialog'; -import { - useChatContext, - useModalContext, - usePollContext, - useTranslationContext, -} from '../../../context'; +import { useModalContext, usePollContext, useTranslationContext } from '../../../context'; import { Button } from '../../Button'; -import { addNotificationTargetTag, useNotificationTarget } from '../../Notifications'; +import { useNotificationApi } from '../../Notifications'; export const EndPollAlert = () => { - const { client } = useChatContext(); + const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); const { poll } = usePollContext(); const { close } = useModalContext(); - const panel = useNotificationTarget(); return ( @@ -33,23 +27,19 @@ export const EndPollAlert = () => { try { await poll.close(); close(); - client.notifications.addSuccess({ + addNotification({ + emitter: 'EndPollAlert', message: t('Poll ended'), - options: { - tags: addNotificationTargetTag(panel), - type: 'api:poll:end:success', - }, - origin: { emitter: 'EndPollAlert' }, + severity: 'success', + type: 'api:poll:end:success', }); } catch (e) { - client.notifications.addError({ + addNotification({ + emitter: 'EndPollAlert', + error: e instanceof Error ? e : undefined, message: t('Failed to end the poll'), - options: { - originalError: e instanceof Error ? e : undefined, - tags: addNotificationTargetTag(panel), - type: 'api:poll:end:failed', - }, - origin: { emitter: 'EndPollAlert' }, + severity: 'error', + type: 'api:poll:end:failed', }); } }} From 9734cbdfab729bec6f6395daed8a7b4e185a3a09 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 16:28:17 +0200 Subject: [PATCH 03/11] feat: migrate to useNotificationApi.removeNotification & useNotificationApi.startNotificationTimeout --- src/components/Notifications/Notification.tsx | 6 ++-- .../Notifications/NotificationList.tsx | 12 ++++---- .../__tests__/NotificationList.test.tsx | 28 ++++++++----------- .../__tests__/useNotificationApi.test.ts | 20 +++++++++++++ .../Notifications/hooks/useNotificationApi.ts | 20 ++++++++++++- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx index e37ba670e..4cd127287 100644 --- a/src/components/Notifications/Notification.tsx +++ b/src/components/Notifications/Notification.tsx @@ -10,9 +10,9 @@ import { IconRefresh, IconXmark, } from '../../components/Icons'; -import { useChatContext } from '../../context/ChatContext'; import { useTranslationContext } from '../../context/TranslationContext'; import { Button } from '../Button'; +import { useNotificationApi } from './hooks/useNotificationApi'; type NotificationEntryDirection = 'bottom' | 'left' | 'right' | 'top'; type NotificationTransitionState = 'enter' | 'exit'; @@ -72,7 +72,7 @@ export const Notification = forwardRef( }: NotificationProps, ref, ) => { - const { client } = useChatContext(); + const { removeNotification } = useNotificationApi(); const { t } = useTranslationContext(); const displayMessage = t('translationBuilderTopic/notification', { @@ -86,7 +86,7 @@ export const Notification = forwardRef( return; } - client.notifications.remove(notification.id); + removeNotification(notification.id); }; const isPersistent = !notification.duration; diff --git a/src/components/Notifications/NotificationList.tsx b/src/components/Notifications/NotificationList.tsx index 326ebe54a..b5d321af8 100644 --- a/src/components/Notifications/NotificationList.tsx +++ b/src/components/Notifications/NotificationList.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import type { Notification } from 'stream-chat'; -import { useChatContext } from '../../context'; +import { useNotificationApi } from './hooks/useNotificationApi'; import { useNotifications } from './hooks/useNotifications'; import { Notification as NotificationComponent } from './Notification'; @@ -71,7 +71,7 @@ export const NotificationList = ({ panel, verticalAlignment = 'bottom', }: NotificationListProps) => { - const { client } = useChatContext(); + const { removeNotification, startNotificationTimeout } = useNotificationApi(); const exitTimeoutRef = useRef(null); const latestNotificationRef = useRef(null); const listRef = useRef(null); @@ -92,9 +92,9 @@ export const NotificationList = ({ const dismiss = useCallback( (id: string) => { startedTimeoutIdsRef.current?.delete(id); - client.notifications.remove(id); + removeNotification(id); }, - [client], + [removeNotification], ); useEffect(() => { @@ -155,7 +155,7 @@ export const NotificationList = ({ return; startedTimeoutIdsRef.current.add(notification.id); - client.notifications.startTimeout(notification.id); + startNotificationTimeout(notification.id); }; if (typeof IntersectionObserver === 'undefined') { @@ -182,7 +182,7 @@ export const NotificationList = ({ return () => { observer.disconnect(); }; - }, [client, notification, transitionState]); + }, [notification, startNotificationTimeout, transitionState]); if (!notification) return null; diff --git a/src/components/Notifications/__tests__/NotificationList.test.tsx b/src/components/Notifications/__tests__/NotificationList.test.tsx index 7b4fb8c54..0d5f0f294 100644 --- a/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -1,22 +1,20 @@ import React from 'react'; import { act, fireEvent, render, screen } from '@testing-library/react'; -import { fromPartial } from '@total-typescript/shoehorn'; -import { useChatContext } from '../../../context'; -import type { ChatContextValue } from '../../../context'; import { NotificationList } from '../NotificationList'; +import { useNotificationApi } from '../hooks/useNotificationApi'; import { useNotifications } from '../hooks/useNotifications'; import type { Notification } from 'stream-chat'; -vi.mock('../../../context', () => ({ - useChatContext: vi.fn(), -})); - vi.mock('../hooks/useNotifications', () => ({ useNotifications: vi.fn(), })); +vi.mock('../hooks/useNotificationApi', () => ({ + useNotificationApi: vi.fn(), +})); + vi.mock('../Notification', () => { const MockNotification = React.forwardRef( ( @@ -50,10 +48,9 @@ vi.mock('../Notification', () => { return { Notification: MockNotification }; }); -const mockedUseChatContext = vi.mocked(useChatContext); +const mockedUseNotificationApi = vi.mocked(useNotificationApi); const mockedUseNotifications = vi.mocked(useNotifications); -const clearTimeout = vi.fn(); const remove = vi.fn(); const startTimeout = vi.fn(); @@ -114,11 +111,11 @@ describe('NotificationList', () => { vi.useFakeTimers({ shouldAdvanceTime: true }); observerEntries.splice(0, observerEntries.length); currentNotifications = [...notifications]; - mockedUseChatContext.mockReturnValue( - fromPartial({ - client: { notifications: { clearTimeout, remove, startTimeout } }, - }), - ); + mockedUseNotificationApi.mockReturnValue({ + addNotification: vi.fn(), + removeNotification: remove, + startNotificationTimeout: startTimeout, + }); remove.mockImplementation((id: string) => { currentNotifications = currentNotifications.filter( (notification) => notification.id !== id, @@ -130,10 +127,9 @@ describe('NotificationList', () => { afterEach(() => { vi.useRealTimers(); - clearTimeout.mockReset(); remove.mockReset(); startTimeout.mockReset(); - mockedUseChatContext.mockReset(); + mockedUseNotificationApi.mockReset(); mockedUseNotifications.mockReset(); delete window['IntersectionObserver']; }); diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts index 318bfc6fd..8876657e5 100644 --- a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts @@ -14,6 +14,8 @@ vi.mock('../useNotificationTarget', () => ({ })); const add = vi.fn(); +const remove = vi.fn(); +const startTimeout = vi.fn(); const mockedUseChatContext = vi.mocked(useChatContext); const mockedUseNotificationTarget = vi.mocked(useNotificationTarget); @@ -26,6 +28,8 @@ describe('useNotificationApi', () => { client: { notifications: { add, + remove, + startTimeout, }, }, }), @@ -36,6 +40,22 @@ describe('useNotificationApi', () => { vi.clearAllMocks(); }); + it('removes notification by id', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.removeNotification('notification-id'); + + expect(remove).toHaveBeenCalledWith('notification-id'); + }); + + it('starts notification timeout by id', () => { + const { result } = renderHook(() => useNotificationApi()); + + result.current.startNotificationTimeout('notification-id'); + + expect(startTimeout).toHaveBeenCalledWith('notification-id'); + }); + it('adds inferred target panel tag when targetPanels is not provided', () => { mockedUseNotificationTarget.mockReturnValue('thread'); diff --git a/src/components/Notifications/hooks/useNotificationApi.ts b/src/components/Notifications/hooks/useNotificationApi.ts index 5282277a1..2deb24910 100644 --- a/src/components/Notifications/hooks/useNotificationApi.ts +++ b/src/components/Notifications/hooks/useNotificationApi.ts @@ -52,9 +52,13 @@ export type AddNotificationParams = { }; export type AddNotification = (params: AddNotificationParams) => void; +export type RemoveNotification = (id: string) => void; +export type StartNotificationTimeout = (id: string) => void; export type NotificationApi = { addNotification: AddNotification; + removeNotification: RemoveNotification; + startNotificationTimeout: StartNotificationTimeout; }; const getTargetTags = ( @@ -128,5 +132,19 @@ export const useNotificationApi = (): NotificationApi => { [client, inferredPanel], ); - return { addNotification }; + const removeNotification: RemoveNotification = useCallback( + (id) => { + client.notifications.remove(id); + }, + [client], + ); + + const startNotificationTimeout: StartNotificationTimeout = useCallback( + (id) => { + client.notifications.startTimeout(id); + }, + [client], + ); + + return { addNotification, removeNotification, startNotificationTimeout }; }; From 274dcdae3582caf86c42bcfef68f7b5a07505eb2 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 17:15:23 +0200 Subject: [PATCH 04/11] fix: add missing icon IconMinus to NumericInput --- src/components/Form/NumericInput.tsx | 4 ++-- src/components/Icons/icons.tsx | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx index 451284f7d..4ec6b4b41 100644 --- a/src/components/Form/NumericInput.tsx +++ b/src/components/Form/NumericInput.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React, { forwardRef, useCallback } from 'react'; import type { ChangeEvent, ComponentProps, KeyboardEvent } from 'react'; import { useStableId } from '../UtilityComponents/useStableId'; -import { IconPlusSmall } from '../Icons'; +import { IconMinus, IconPlusSmall } from '../Icons'; import { Button } from '../Button'; export type NumericInputProps = Omit< @@ -127,7 +127,7 @@ export const NumericInput = forwardRef( variant='secondary' > - − + , ); -// was: IconCircleMinus export const IconMinusCircle = createIcon( 'IconMinusCircle', , ); +export const IconMinus = createIcon( + 'IconMinus', + , +); + // was: IconCircleX export const IconXCircle = createIcon( 'IconXCircle', From 0f651fbeaf64fdc7669ff7d47f5f91e27ddd1bd1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 17:16:15 +0200 Subject: [PATCH 05/11] fix(demo): make the channel in LoadingScreen take the available height --- examples/vite/src/LoadingScreen/LoadingScreen.tsx | 10 ++++++---- examples/vite/src/index.scss | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/vite/src/LoadingScreen/LoadingScreen.tsx b/examples/vite/src/LoadingScreen/LoadingScreen.tsx index a3d00b1a4..85dcf62bb 100644 --- a/examples/vite/src/LoadingScreen/LoadingScreen.tsx +++ b/examples/vite/src/LoadingScreen/LoadingScreen.tsx @@ -66,10 +66,12 @@ export const LoadingScreen = ({
-
-
-
- +
+
+
+
+ +
diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 527e85bdd..4a8c2ff24 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -172,6 +172,7 @@ body { .app-loading-screen__window { width: 100%; + height: 100%; } .str-chat__list, From 9e3faecdcbd146e9bfa83639ae183c6a369694aa Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 6 Apr 2026 17:17:26 +0200 Subject: [PATCH 06/11] fix(demo): prevent closing NotificationPromptDialog upon opening --- examples/vite/src/App.tsx | 32 +++++++++++-------- .../AppSettings/ActionsMenu/ActionsMenu.tsx | 20 +++++------- .../ActionsMenu/NotificationPromptDialog.tsx | 5 +-- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 35ae6300d..e718c3303 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -18,6 +18,7 @@ import { type AttachmentProps, Chat, ChatView, + DialogManagerProvider, MessageReactions, type NotificationListProps, NotificationList, @@ -172,6 +173,7 @@ const MessageUiOverride = messageUiVariant const systemMessageVariant = getSystemMessageVariant(); const reactionsVariant = getReactionsVariant(); const attachmentActionsVariant = getAttachmentActionsVariant(); +const globalDialogManager = 'globalDialogManager'; const CustomAttachmentWithActions = (props: AttachmentProps) => ( @@ -381,20 +383,22 @@ const App = () => { layoutRef={appLayoutRef} /> - - - - + + + + + +
diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx index 29f5803dd..100b55590 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -1,15 +1,13 @@ -import { useMemo, useState } from 'react'; import type { ComponentProps } from 'react'; +import { useState } from 'react'; import { Button, ContextMenu, ContextMenuButton, - DialogManagerProvider, IconBolt, useContextMenuContext, useDialogIsOpen, useDialogOnNearestManager, - type ContextMenuItemComponent, } from 'stream-chat-react'; import { NotificationPromptDialog, @@ -57,23 +55,18 @@ const ActionsMenuButton = ({ ); export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => ( - - - + ); -function TriggerNotification() { +function TriggerNotificationAction({ onTrigger }: { onTrigger: () => void }) { const { closeMenu } = useContextMenuContext(); - const { dialog: notificationDialog } = useDialogOnNearestManager({ - id: notificationPromptDialogId, - }); return ( { closeMenu(); - notificationDialog.open(); + onTrigger(); }} /> ); @@ -86,6 +79,9 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { const { dialog: actionsMenuDialog, dialogManager } = useDialogOnNearestManager({ id: actionsMenuDialogId, }); + const { dialog: notificationDialog } = useDialogOnNearestManager({ + id: notificationPromptDialogId, + }); const menuIsOpen = useDialogIsOpen(actionsMenuDialogId, dialogManager?.id); @@ -108,7 +104,7 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { tabIndex={-1} trapFocus > - +
diff --git a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx index 3434393b2..a050236c6 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/NotificationPromptDialog.tsx @@ -196,16 +196,17 @@ const NotificationDraftForm = ({ options={severityOptions} value={draft.severity} /> -