-
+
diff --git a/examples/vite/src/SystemNotification/SystemNotification.scss b/examples/vite/src/SystemNotification/SystemNotification.scss
new file mode 100644
index 0000000000..07fc12aac7
--- /dev/null
+++ b/examples/vite/src/SystemNotification/SystemNotification.scss
@@ -0,0 +1,84 @@
+.str-chat__system-notification {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-xs, 8px);
+ padding-block: var(--spacing-xs, 8px);
+ padding-inline: var(--spacing-sm, 12px);
+ background: var(--background-core-surface-default, #ebeef1);
+ color: var(--chat-text-system, #414552);
+ font-feature-settings:
+ 'liga' off,
+ 'clig' off;
+ font-size: var(--typography-font-size-xs, 12px);
+ font-style: normal;
+ font-weight: var(--typography-font-weight-semi-bold, 600);
+ line-height: var(--typography-line-height-tight, 16px);
+ animation: str-chat__system-notification-slide-in 300ms ease-out both;
+ overflow: hidden;
+ width: 100%;
+ z-index: 2;
+}
+
+.str-chat__system-notification--exiting {
+ animation: str-chat__system-notification-slide-out 300ms ease-in both;
+}
+
+.str-chat__system-notification--interactive {
+ cursor: pointer;
+}
+
+.str-chat__system-notification-icon {
+ align-items: center;
+ display: inline-flex;
+ flex-shrink: 0;
+}
+
+.str-chat__system-notification-message {
+ white-space: nowrap;
+ overflow-y: visible;
+ overflow-x: hidden;
+ overflow-x: clip;
+ text-overflow: ellipsis;
+}
+
+.str-chat__system-notification--loading .str-chat__system-notification-icon {
+ animation: str-chat__system-notification-spin 1.5s linear infinite;
+}
+
+@keyframes str-chat__system-notification-slide-in {
+ from {
+ max-height: 0;
+ opacity: 0;
+ padding-block: 0;
+ }
+
+ to {
+ max-height: 4rem;
+ opacity: 1;
+ }
+}
+
+@keyframes str-chat__system-notification-slide-out {
+ from {
+ max-height: 4rem;
+ opacity: 1;
+ }
+
+ to {
+ max-height: 0;
+ opacity: 0;
+ padding-block: 0;
+ }
+}
+
+@keyframes str-chat__system-notification-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/examples/vite/src/SystemNotification/SystemNotification.tsx b/examples/vite/src/SystemNotification/SystemNotification.tsx
new file mode 100644
index 0000000000..dd9460d0b3
--- /dev/null
+++ b/examples/vite/src/SystemNotification/SystemNotification.tsx
@@ -0,0 +1,108 @@
+import { type ComponentType, useEffect, useState } from 'react';
+import clsx from 'clsx';
+import type { Notification, NotificationSeverity } from 'stream-chat';
+
+import {
+ IconCheckmark,
+ IconExclamationCircleFill,
+ IconExclamationTriangleFill,
+ IconLoading,
+ createIcon,
+ useSystemNotifications,
+} from 'stream-chat-react';
+
+export const IconInfoCircle = createIcon(
+ 'IconInfoCircle',
+
,
+);
+const IconsBySeverity: Record
= {
+ error: IconExclamationCircleFill,
+ info: IconInfoCircle,
+ loading: IconLoading,
+ success: IconCheckmark,
+ warning: IconExclamationTriangleFill,
+};
+
+type SystemNotificationFilter = (notification: Notification) => boolean;
+
+export type SystemNotificationProps = {
+ /** Optional class name for the container */
+ className?: string;
+ /** Optional additional filter applied after the default system-tag filter. */
+ filter?: SystemNotificationFilter;
+};
+
+const SLIDE_OUT_ANIMATION_NAME = 'str-chat__system-notification-slide-out';
+
+export const SystemNotification = ({ className, filter }: SystemNotificationProps) => {
+ const notifications = useSystemNotifications(filter ? { filter } : undefined);
+ const notification = notifications[0];
+
+ const [retainedNotification, setRetainedNotification] = useState<
+ Notification | undefined
+ >(notification);
+
+ useEffect(() => {
+ if (notification) {
+ setRetainedNotification(notification);
+ }
+ }, [notification]);
+
+ const isExiting = !notification && !!retainedNotification;
+ const rendered = notification ?? retainedNotification;
+
+ if (!rendered) return null;
+
+ const Icon = rendered.severity
+ ? (IconsBySeverity[rendered.severity] ?? null)
+ : IconExclamationCircleFill;
+ const action = rendered.actions?.[0];
+
+ return (
+ {
+ if (e.animationName === SLIDE_OUT_ANIMATION_NAME) {
+ setRetainedNotification(undefined);
+ }
+ }}
+ onClick={action?.handler}
+ onKeyDown={
+ action
+ ? (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ action.handler();
+ }
+ }
+ : undefined
+ }
+ role={action ? 'button' : 'status'}
+ tabIndex={action ? 0 : undefined}
+ >
+ {Icon && (
+
+
+
+ )}
+ {rendered.message}
+
+ );
+};
diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss
index e285aaa1da..e9706de058 100644
--- a/examples/vite/src/index.scss
+++ b/examples/vite/src/index.scss
@@ -3,6 +3,7 @@
// v3 CSS import
@import url('stream-chat-react/dist/css/index.css') layer(stream-new);
@import url('./AppSettings/AppSettings.scss') layer(stream-app-overrides);
+@import url('./SystemNotification/SystemNotification.scss') layer(stream-app-overrides);
:root {
font-synthesis: none;
@@ -17,7 +18,9 @@ body {
#root {
display: flex;
+ flex-direction: column;
height: 100vh;
+ min-height: 0;
}
@layer stream-overrides {
@@ -25,10 +28,29 @@ body {
--app-left-panel-width: 360px;
--app-thread-panel-width: 360px;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
height: 100%;
width: 100%;
}
+ /* Fills viewport minus in-flow `.str-chat__system-notification` when present */
+ .app-chat-layout__body {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ }
+
+ /* ChatView root is the only in-DOM child of __body (sync components return null) */
+ .app-chat-layout__body > .str-chat__chat-view {
+ flex: 1 1 auto;
+ min-height: 0;
+ }
+
.app-chat-layout .str-chat {
--str-chat__channel-list-width: var(--app-left-panel-width);
--str-chat__thread-list-width: var(--app-left-panel-width);
@@ -172,6 +194,7 @@ body {
.app-loading-screen__window {
width: 100%;
+ height: 100%;
}
.str-chat__list,
diff --git a/src/components/Attachment/__tests__/Audio.test.tsx b/src/components/Attachment/__tests__/Audio.test.tsx
index 24ccffc5de..f821e68c71 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,15 @@ vi.mock('../../../context/TranslationContext', () => ({
useTranslationContext: () => ({ t: (s) => tSpy(s) }),
}));
vi.mock('../../Notifications', () => ({
+ useNotificationApi: () => ({
+ addNotification: addNotificationSpy,
+ addSystemNotification: vi.fn(),
+ }),
useNotificationTarget: () => 'channel',
}));
-const addErrorSpy = vi.fn();
const mockClient = {
- notifications: { addError: addErrorSpy },
+ notifications: { add: addNotificationSpy },
};
const tSpy = (s) => s;
@@ -75,8 +82,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 +168,7 @@ describe('Audio', () => {
await clickToPause();
expect(playButton()).toBeInTheDocument();
- expect(addErrorSpy).not.toHaveBeenCalled();
+ expect(addNotificationSpy).not.toHaveBeenCalled();
audioPausedMock.mockRestore();
});
@@ -188,7 +195,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 67f6d0662d..a706f6980c 100644
--- a/src/components/Attachment/__tests__/VoiceRecording.test.tsx
+++ b/src/components/Attachment/__tests__/VoiceRecording.test.tsx
@@ -14,6 +14,10 @@ import { ResizeObserverMock } from '../../../mock-builders/browser';
import { WithAudioPlayback } from '../../AudioPlayback';
vi.mock('../../Notifications', () => ({
+ useNotificationApi: () => ({
+ addNotification: vi.fn(),
+ addSystemNotification: vi.fn(),
+ }),
useNotificationTarget: () => 'channel',
}));
diff --git a/src/components/AudioPlayback/WithAudioPlayback.tsx b/src/components/AudioPlayback/WithAudioPlayback.tsx
index b982125997..b2c4eeaefc 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 00922b6627..af153896b6 100644
--- a/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx
+++ b/src/components/AudioPlayback/__tests__/WithAudioPlayback.test.tsx
@@ -5,34 +5,32 @@ 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,
+ addSystemNotification: vi.fn(),
+ }),
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 +65,7 @@ afterEach(() => {
cleanup();
vi.resetAllMocks();
createdAudios.length = 0;
- // addErrorSpy.mockReset();
+ // mockAddNotification.mockReset();
// defaultRegisterSpy.mockClear();
});
@@ -277,11 +275,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 +297,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 +305,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 +325,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 abd408ef27..3d6fc1b8a8 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 f0e2877d79..2677551bdc 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 6729ab62df..e962dfd2e8 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 09a7ddb81d..ce720891db 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 b563b8fd4d..1138b4cfa9 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,
@@ -40,21 +39,37 @@ const useMuteActionButtonBehavior = () => {
setInProgress(true);
if (isMuted) {
await channel.unmute();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel unmuted'),
+ severity: 'success',
+ type: 'api:channel:unmute:success',
+ });
} else {
await channel.mute();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel muted'),
+ severity: 'success',
+ type: 'api:channel:mute:success',
+ });
}
} catch (error) {
- client.notifications.addError({
- 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,
+ addNotification({
+ context: {
+ channel,
},
+ emitter: ChannelListItemActionButtons.name,
+ error: error instanceof Error ? error : new Error('An unknown error occurred'),
+ message: t('Failed to update channel mute status'),
+ severity: 'error',
+ type: 'api:channel:mute:failed',
});
} finally {
setInProgress(false);
@@ -66,11 +81,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',
@@ -81,21 +95,37 @@ const useArchiveActionButtonBehavior = () => {
setInProgress(true);
if (membership.archived_at) {
await channel.unarchive();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel unarchived'),
+ severity: 'success',
+ type: 'api:channel:unarchive:success',
+ });
} else {
await channel.archive();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel archived'),
+ severity: 'success',
+ type: 'api:channel:archive:success',
+ });
}
} catch (error) {
- client.notifications.addError({
- 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,
+ addNotification({
+ context: {
+ channel,
},
+ emitter: ChannelListItemActionButtons.name,
+ error: error instanceof Error ? error : new Error('An unknown error occurred'),
+ message: t('Failed to update channel archive status'),
+ severity: 'error',
+ type: 'api:channel:archive:failed',
});
} finally {
setInProgress(false);
@@ -226,11 +256,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,
);
@@ -254,23 +284,38 @@ export const defaultChannelActionSet: ChannelActionItem[] = [
if (isUserBanned) {
await channel.unbanUser(otherUserId);
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('User unblocked'),
+ severity: 'success',
+ type: 'api:user:unban:success',
+ });
} else {
await channel.banUser(otherUserId, {});
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('User blocked'),
+ severity: 'success',
+ type: 'api:user:ban:success',
+ });
}
} catch (error) {
- client.notifications.addError({
- 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,
+ addNotification({
+ context: {
+ channel,
},
+ emitter: ChannelListItemActionButtons.name,
+ error:
+ error instanceof Error ? error : new Error('An unknown error occurred'),
+ message: t('Failed to block user'),
+ severity: 'error',
+ type: 'api:user:ban:failed',
});
} finally {
setInProgress(false);
@@ -287,7 +332,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 +341,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');
@@ -312,21 +356,38 @@ export const defaultChannelActionSet: ChannelActionItem[] = [
setInProgress(true);
if (membership.pinned_at) {
await channel.unpin();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel unpinned'),
+ severity: 'success',
+ type: 'api:channel:unpin:success',
+ });
} else {
await channel.pin();
+ addNotification({
+ context: {
+ channel,
+ },
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Channel pinned'),
+ severity: 'success',
+ type: 'api:channel:pin:success',
+ });
}
} catch (e) {
error = e instanceof Error ? e : new Error('An unknown error occurred');
- client.notifications.addError({
- message: t('Failed to update channel pinned status'),
- options: {
- originalError: error,
- tags: addNotificationTargetTag(panel),
- type: 'channelListItem:pin:failed',
- },
- origin: {
- emitter: ChannelListItemActionButtons.name,
+ addNotification({
+ context: {
+ channel,
},
+ emitter: ChannelListItemActionButtons.name,
+ error,
+ message: t('Failed to update channel pinned status'),
+ severity: 'error',
+ type: 'api:channel:pin:failed',
});
} finally {
if (!error) dialog?.close();
@@ -347,8 +408,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');
@@ -363,20 +424,26 @@ export const defaultChannelActionSet: ChannelActionItem[] = [
setInProgress(true);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await channel.removeMembers([client.userID!]);
- } catch (error) {
- client.notifications.addError({
- 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',
+ addNotification({
+ context: {
+ channel,
},
- origin: {
- emitter: ChannelListItemActionButtons.name,
+ emitter: ChannelListItemActionButtons.name,
+ message: t('Left channel'),
+ severity: 'success',
+ type: 'api:channel:leave:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ channel,
},
+ emitter: ChannelListItemActionButtons.name,
+ error:
+ error instanceof Error ? error : new Error('An unknown error occurred'),
+ message: t('Failed to leave channel'),
+ severity: 'error',
+ type: 'api:channel:leave:failed',
});
} finally {
setInProgress(false);
diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx
index 53c2fca8e4..7dbf6a602c 100644
--- a/src/components/Chat/Chat.tsx
+++ b/src/components/Chat/Chat.tsx
@@ -1,23 +1,29 @@
+import type { PropsWithChildren } from 'react';
import React, { useMemo } from 'react';
+import type { StreamChat } from 'stream-chat';
import {
ChannelSearchSource,
MessageSearchSource,
SearchController,
UserSearchSource,
} from 'stream-chat';
-import type { PropsWithChildren } from 'react';
-import type { StreamChat } from 'stream-chat';
import { useChat } from './hooks/useChat';
+import { useReportLostConnectionSystemNotification } from './hooks/useReportLostConnectionSystemNotification';
import { useCreateChatContext } from './hooks/useCreateChatContext';
import { useChannelsQueryState } from './hooks/useChannelsQueryState';
+import type { CustomClasses } from '../../context/ChatContext';
import { ChatProvider } from '../../context/ChatContext';
import { TranslationProvider } from '../../context/TranslationContext';
-import type { CustomClasses } from '../../context/ChatContext';
import { type MessageContextValue, ModalDialogManagerProvider } from '../../context';
import type { SupportedTranslations } from '../../i18n/types';
import type { Streami18n } from '../../i18n/Streami18n';
+const NetworkConnectionNotificationReporter = () => {
+ useReportLostConnectionSystemNotification();
+ return null;
+};
+
export type ChatProps = {
/** The StreamChat client object */
client: StreamChat;
@@ -71,6 +77,7 @@ export const Chat = (props: PropsWithChildren) => {
i18nInstance,
});
+ useReportLostConnectionSystemNotification();
const channelsQueryState = useChannelsQueryState();
const searchController = useMemo(
@@ -106,7 +113,10 @@ export const Chat = (props: PropsWithChildren) => {
return (
- {children}
+
+
+ {children}
+
);
diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx
index 9ef07b863d..ec6db54abb 100644
--- a/src/components/Chat/__tests__/Chat.test.tsx
+++ b/src/components/Chat/__tests__/Chat.test.tsx
@@ -10,6 +10,7 @@ import type { ChatContextValue } from '../../../context';
import { Streami18n } from '../../../i18n';
import type { Mute } from 'stream-chat';
import {
+ dispatchConnectionChangedEvent,
dispatchNotificationMutesUpdated,
getTestClient,
getTestClientWithUser,
@@ -207,6 +208,41 @@ describe('Chat', () => {
});
});
+ describe('connection notifications', () => {
+ it('publishes and removes system connection-lost notification on connection changes', async () => {
+ const client = getTestClient();
+ let connectionLostNotification;
+
+ render(
+
+
+ ,
+ );
+
+ expect(client.notifications.notifications).toHaveLength(0);
+
+ act(() => dispatchConnectionChangedEvent(client, false));
+ await waitFor(() => {
+ connectionLostNotification = client.notifications.notifications.find(
+ (notification) => notification.origin.emitter === 'Chat',
+ );
+ expect(connectionLostNotification).toBeDefined();
+ });
+
+ expect(connectionLostNotification.message).toBe('Waiting for network…');
+ expect(connectionLostNotification.tags).toEqual(['system']);
+
+ act(() => dispatchConnectionChangedEvent(client, true));
+ await waitFor(() => {
+ expect(
+ client.notifications.notifications.find(
+ (notification) => notification.origin.emitter === 'Chat',
+ ),
+ ).toBeUndefined();
+ });
+ });
+ });
+
describe('translation context', () => {
it('should expose the context', async () => {
let context: ChatContextValue;
diff --git a/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts
new file mode 100644
index 0000000000..fc271c4a12
--- /dev/null
+++ b/src/components/Chat/hooks/useReportLostConnectionSystemNotification.ts
@@ -0,0 +1,50 @@
+import { useEffect, useRef } from 'react';
+
+import { useChatContext } from '../../../context/ChatContext';
+import { useTranslationContext } from '../../../context/TranslationContext';
+import { useNotificationApi } from '../../Notifications/hooks/useNotificationApi';
+
+/**
+ * Publishes a persistent system notification while the client is offline and removes it when
+ * back online. Must run under `ChatProvider` and `TranslationProvider` (e.g. from a child of ``).
+ */
+export const useReportLostConnectionSystemNotification = () => {
+ const { t } = useTranslationContext();
+ const { client } = useChatContext();
+ const { addSystemNotification, removeNotification } = useNotificationApi();
+ const connectionLostNotificationIdRef = useRef(null);
+
+ useEffect(() => {
+ if (!t || !client) return;
+
+ const dismissConnectionLostNotification = () => {
+ if (!connectionLostNotificationIdRef.current) return;
+ removeNotification(connectionLostNotificationIdRef.current);
+ connectionLostNotificationIdRef.current = null;
+ };
+
+ const handleConnectionChanged = ({ online }: { online?: boolean }) => {
+ if (!online) {
+ if (connectionLostNotificationIdRef.current) return;
+
+ connectionLostNotificationIdRef.current = addSystemNotification({
+ duration: 0,
+ emitter: 'Chat',
+ message: t('Waiting for network…'),
+ severity: 'loading',
+ type: 'system:network:connection:lost',
+ });
+ return;
+ }
+
+ dismissConnectionLostNotification();
+ };
+
+ client.on('connection.changed', handleConnectionChanged);
+
+ return () => {
+ client.off('connection.changed', handleConnectionChanged);
+ dismissConnectionLostNotification();
+ };
+ }, [addSystemNotification, client, removeNotification, t]);
+};
diff --git a/src/components/Chat/index.ts b/src/components/Chat/index.ts
index 072517bfc9..68e891507f 100644
--- a/src/components/Chat/index.ts
+++ b/src/components/Chat/index.ts
@@ -1,3 +1,4 @@
export * from './Chat';
export * from './hooks/useChat';
+export * from './hooks/useReportLostConnectionSystemNotification';
export * from './hooks/useCreateChatClient';
diff --git a/src/components/Form/NumericInput.tsx b/src/components/Form/NumericInput.tsx
index 451284f7dc..4ec6b4b419 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',
@@ -564,7 +571,6 @@ export const IconFlag = createIcon(
/>,
);
-// was: IconImages1Alt
export const IconImage = createIcon(
'IconImage',
{
- 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 22613bad5f..414fee6214 100644
--- a/src/components/Location/__tests__/ShareLocationDialog.test.tsx
+++ b/src/components/Location/__tests__/ShareLocationDialog.test.tsx
@@ -28,8 +28,11 @@ 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(), addSystemNotification: vi.fn() }),
useNotificationTarget: vi.fn().mockReturnValue('channel'),
}));
diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx
index 51aa626290..d5c0116685 100644
--- a/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx
+++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorderRecordingControls.tsx
@@ -1,14 +1,10 @@
import { CheckSignIcon } 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';
import { UploadProgressIndicator } from '../../Loading/UploadProgressIndicator';
const ToggleRecordingButton = () => {
@@ -33,13 +29,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';
const uploadProgress = recording?.localMetadata?.uploadProgress;
@@ -56,13 +50,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 32714c04cf..b94deb35de 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,10 @@ vi.mock('../../../../context', () => ({
vi.mock('../../../Notifications', async (importOriginal) => ({
...(await importOriginal()),
+ useNotificationApi: () => ({
+ addNotification: addNotificationSpy,
+ addSystemNotification: vi.fn(),
+ }),
useNotificationTarget: () => 'channel',
}));
diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx
index 5c9cd22d5d..ba87e97555 100644
--- a/src/components/Message/Message.tsx
+++ b/src/components/Message/Message.tsx
@@ -25,7 +25,6 @@ import {
useComponentContext,
useMessageTranslationViewContext,
} from '../../context';
-import { addNotificationTargetTag, useNotificationTarget } from '../Notifications';
import { MessageUI as DefaultMessageUI } from './MessageUI';
@@ -193,15 +192,6 @@ export const Message = (props: MessageProps) => {
const {
closeReactionSelectorOnClick,
disableQuotedMessages,
- getDeleteMessageErrorNotification,
- getFetchReactionsErrorNotification,
- getFlagMessageErrorNotification,
- getFlagMessageSuccessNotification,
- getMarkMessageUnreadErrorNotification,
- getMarkMessageUnreadSuccessNotification,
- getMuteUserErrorNotification,
- getMuteUserSuccessNotification,
- getPinMessageErrorNotification,
message,
onlySenderCanEdit = false,
onMentionsClick: propOnMentionsClick,
@@ -212,22 +202,7 @@ export const Message = (props: MessageProps) => {
sortReactions,
} = props;
- const { client } = useChatContext('Message');
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 });
- }
- },
- [client, panel],
- );
const handleAction = useActionHandler(message);
const handleOpenThread = useOpenThreadHandler(message, propOpenThread);
@@ -235,43 +210,22 @@ export const Message = (props: MessageProps) => {
const handleRetry = useRetryHandler(propRetrySendMessage);
const userRoles = useUserRole(message, onlySenderCanEdit, disableQuotedMessages);
- const handleFetchReactions = useReactionsFetcher(message, {
- getErrorNotification: getFetchReactionsErrorNotification,
- notify,
- });
+ const handleFetchReactions = useReactionsFetcher(message);
- const handleDelete = useDeleteHandler(message, {
- getErrorNotification: getDeleteMessageErrorNotification,
- notify,
- });
+ const handleDelete = useDeleteHandler(message);
- const handleFlag = useFlagHandler(message, {
- getErrorNotification: getFlagMessageErrorNotification,
- getSuccessNotification: getFlagMessageSuccessNotification,
- notify,
- });
+ const handleFlag = useFlagHandler(message);
- const handleMarkUnread = useMarkUnreadHandler(message, {
- getErrorNotification: getMarkMessageUnreadErrorNotification,
- getSuccessNotification: getMarkMessageUnreadSuccessNotification,
- notify,
- });
+ const handleMarkUnread = useMarkUnreadHandler(message);
- const handleMute = useMuteHandler(message, {
- getErrorNotification: getMuteUserErrorNotification,
- getSuccessNotification: getMuteUserSuccessNotification,
- notify,
- });
+ const handleMute = useMuteHandler(message);
const { onMentionsClick, onMentionsHover } = useMentionsHandler(message, {
onMentionsClick: propOnMentionsClick,
onMentionsHover: propOnMentionsHover,
});
- const { canPin, handlePin } = usePinHandler(message, {
- getErrorNotification: getPinMessageErrorNotification,
- notify,
- });
+ const { canPin, handlePin } = usePinHandler(message);
const highlighted = highlightedMessageId === message.id;
diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx
index 8d722f4f77..29d9fe48c6 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 dbf8c69639..688d349dc8 100644
--- a/src/components/Message/__tests__/Message.test.tsx
+++ b/src/components/Message/__tests__/Message.test.tsx
@@ -401,13 +401,10 @@ describe(' component', () => {
expect(onUserHoverMock).toHaveBeenCalledWith(mouseEventMock, message.user);
});
- it('should allow to mute a user and notify with custom success notification when it is successful', async () => {
+ it('should allow to mute a user when it is successful', async () => {
const message = generateMessage({ user: bob });
const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
const muteUser = vi.fn(() => Promise.resolve());
- const userMutedNotification = 'User muted!';
- const getMuteUserSuccessNotification = vi.fn(() => userMutedNotification);
// @ts-expect-error - mock implementation has simplified signature
vi.spyOn(client, 'muteUser').mockImplementation(muteUser);
let context: MessageContextValue;
@@ -419,83 +416,17 @@ describe(' component', () => {
context = ctx;
},
message,
- props: { getMuteUserSuccessNotification },
});
await context.handleMute(mouseEventMock);
expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: userMutedNotification }),
- );
- addSuccessSpy.mockRestore();
- });
-
- it('should allow to mute a user and notify with default success notification when it is successful', async () => {
- const message = generateMessage({ user: bob });
- const defaultSuccessMessage = '{{ user }} has been muted';
- const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
- const muteUser = vi.fn(() => Promise.resolve());
- // @ts-expect-error - mock implementation has simplified signature
- vi.spyOn(client, 'muteUser').mockImplementation(muteUser);
- let context: MessageContextValue;
-
- await renderComponent({
- channelStateOpts: { mutes: [] },
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- render,
- });
-
- await context.handleMute(mouseEventMock);
-
- expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultSuccessMessage }),
- );
- addSuccessSpy.mockRestore();
- });
-
- 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 muteUser = vi.fn(() => Promise.reject());
- const userMutedFailNotification = 'User mute failed!';
- const getMuteUserErrorNotification = vi.fn(() => userMutedFailNotification);
- vi.spyOn(client, 'muteUser').mockImplementation(muteUser);
- let context: MessageContextValue;
-
- await renderComponent({
- channelStateOpts: { mutes: [] },
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- props: { getMuteUserErrorNotification },
- render,
- });
-
- await context.handleMute(mouseEventMock);
-
- expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: userMutedFailNotification }),
- );
- addErrorSpy.mockRestore();
});
- it('should allow to mute a user and notify with default error message when muting a user fails', async () => {
+ it('should throw when muting a user fails', async () => {
const message = generateMessage({ user: bob });
const client = await getTestClientWithUser(alice);
- const addErrorSpy = vi.spyOn(client.notifications, 'addError');
- const muteUser = vi.fn(() => Promise.reject());
- const defaultFailNotification = 'Error muting a user ...';
+ const muteUser = vi.fn(() => Promise.reject(new Error('mute failed')));
vi.spyOn(client, 'muteUser').mockImplementation(muteUser);
let context: MessageContextValue;
@@ -509,22 +440,15 @@ describe(' component', () => {
render,
});
- await context.handleMute(mouseEventMock);
+ await expect(context.handleMute(mouseEventMock)).rejects.toThrow('mute failed');
expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultFailNotification }),
- );
- addErrorSpy.mockRestore();
});
- it('should allow to unmute a user and notify with custom success notification when it is successful', async () => {
+ it('should allow to unmute a user when it is successful', async () => {
const message = generateMessage({ user: bob });
const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
const unmuteUser = vi.fn(() => Promise.resolve());
- const userUnmutedNotification = 'User unmuted!';
- const getMuteUserSuccessNotification = vi.fn(() => userUnmutedNotification);
// @ts-expect-error - mock implementation has simplified signature
vi.spyOn(client, 'unmuteUser').mockImplementation(unmuteUser);
let context: MessageContextValue;
@@ -536,26 +460,18 @@ describe(' component', () => {
context = ctx;
},
message,
- props: { getMuteUserSuccessNotification },
render,
});
await context.handleMute(mouseEventMock);
expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: userUnmutedNotification }),
- );
- addSuccessSpy.mockRestore();
});
- it('should allow to unmute a user and notify with default success notification when it is successful', async () => {
+ it('should throw when unmuting a user fails', async () => {
const message = generateMessage({ user: bob });
const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
- const unmuteUser = vi.fn(() => Promise.resolve());
- const defaultSuccessNotification = '{{ user }} has been unmuted';
- // @ts-expect-error - mock implementation has simplified signature
+ const unmuteUser = vi.fn(() => Promise.reject(new Error('unmute failed')));
vi.spyOn(client, 'unmuteUser').mockImplementation(unmuteUser);
let context: MessageContextValue;
@@ -569,71 +485,9 @@ describe(' component', () => {
render,
});
- await context.handleMute(mouseEventMock);
+ await expect(context.handleMute(mouseEventMock)).rejects.toThrow('unmute failed');
expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultSuccessNotification }),
- );
- addSuccessSpy.mockRestore();
- });
-
- 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 unmuteUser = vi.fn(() => Promise.reject());
- const userMutedFailNotification = 'User muted failed!';
- const getMuteUserErrorNotification = vi.fn(() => userMutedFailNotification);
- vi.spyOn(client, 'unmuteUser').mockImplementation(unmuteUser);
- let context: MessageContextValue;
-
- await renderComponent({
- channelStateOpts: { mutes: [fromPartial({ target: { id: bob.id } })] },
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- props: { getMuteUserErrorNotification },
- render,
- });
-
- await context.handleMute(mouseEventMock);
-
- expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: userMutedFailNotification }),
- );
- addErrorSpy.mockRestore();
- });
-
- 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 unmuteUser = vi.fn(() => Promise.reject());
- const defaultFailNotification = 'Error unmuting a user ...';
- vi.spyOn(client, 'unmuteUser').mockImplementation(unmuteUser);
- let context: MessageContextValue;
-
- await renderComponent({
- channelStateOpts: { mutes: [fromPartial({ target: { id: bob.id } })] },
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- render,
- });
-
- await context.handleMute(mouseEventMock);
-
- expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultFailNotification }),
- );
- addErrorSpy.mockRestore();
});
it.each([
@@ -801,15 +655,12 @@ describe(' component', () => {
expect(context.getMessageActions()).toContain(MESSAGE_ACTIONS.mute);
});
- it('should allow to flag a message and notify with custom success notification when it is successful', async () => {
+ it('should allow to flag a message when it is successful', async () => {
const message = generateMessage();
const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
const flagMessage = vi.fn(() => Promise.resolve());
// @ts-expect-error - mock implementation has simplified signature
vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage);
- const messageFlaggedNotification = 'Message flagged!';
- const getFlagMessageSuccessNotification = vi.fn(() => messageFlaggedNotification);
let context: MessageContextValue;
await renderComponent({
@@ -818,27 +669,19 @@ describe(' component', () => {
context = ctx;
},
message,
- props: { getFlagMessageSuccessNotification },
render,
});
await context.handleFlag(mouseEventMock);
expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: messageFlaggedNotification }),
- );
- addSuccessSpy.mockRestore();
});
- it('should allow to flag a message and notify with default success notification when it is successful', async () => {
+ it('should throw when flagging a message fails', async () => {
const message = generateMessage();
const client = await getTestClientWithUser(alice);
- const addSuccessSpy = vi.spyOn(client.notifications, 'addSuccess');
- const flagMessage = vi.fn(() => Promise.resolve());
- // @ts-expect-error - mock implementation has simplified signature
+ const flagMessage = vi.fn(() => Promise.reject(new Error('flag failed')));
vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage);
- const defaultSuccessNotification = 'Message has been successfully flagged';
let context: MessageContextValue;
await renderComponent({
@@ -850,69 +693,9 @@ describe(' component', () => {
render,
});
- await context.handleFlag(mouseEventMock);
+ await expect(context.handleFlag(mouseEventMock)).rejects.toThrow('flag failed');
expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(addSuccessSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultSuccessNotification }),
- );
- addSuccessSpy.mockRestore();
- });
-
- 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 flagMessage = vi.fn(() => Promise.reject());
- vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage);
- const messageFlagFailedNotification = 'Message flagged failed!';
- const getFlagMessageErrorNotification = vi.fn(() => messageFlagFailedNotification);
- let context: MessageContextValue;
-
- await renderComponent({
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- props: { getFlagMessageErrorNotification },
- render,
- });
-
- await context.handleFlag(mouseEventMock);
-
- expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: messageFlagFailedNotification }),
- );
- addErrorSpy.mockRestore();
- });
-
- 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 flagMessage = vi.fn(() => Promise.reject());
- vi.spyOn(client, 'flagMessage').mockImplementation(flagMessage);
- const defaultFlagMessageFailedNotification = 'Error adding flag';
- let context: MessageContextValue;
-
- await renderComponent({
- clientOpts: { client },
- contextCallback: (ctx) => {
- context = ctx;
- },
- message,
- render,
- });
-
- await context.handleFlag(mouseEventMock);
-
- expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(addErrorSpy).toHaveBeenCalledWith(
- expect.objectContaining({ message: defaultFlagMessageFailedNotification }),
- );
- addErrorSpy.mockRestore();
});
it('should allow user to pin messages when permissions allow', async () => {
diff --git a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx
index cdde69c794..d9bdb997e2 100644
--- a/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx
+++ b/src/components/Message/hooks/__tests__/useDeleteHandler.test.tsx
@@ -114,7 +114,6 @@ describe('useDeleteHandler custom hook', () => {
});
it('should reject after notifying when server delete fails', async () => {
- const notify = vi.fn();
const error = new Error('delete failed');
deleteMessage.mockRejectedValueOnce(error);
@@ -126,11 +125,10 @@ describe('useDeleteHandler custom hook', () => {
);
- const { result } = renderHook(() => useDeleteHandler(testMessage, { notify }), {
+ const { result } = renderHook(() => useDeleteHandler(testMessage), {
wrapper,
});
await expect(result.current()).rejects.toThrow('delete failed');
- expect(notify).toHaveBeenCalledWith('Error deleting message', 'error');
});
});
diff --git a/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx b/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx
index 160d83106e..2ac35576ee 100644
--- a/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx
+++ b/src/components/Message/hooks/__tests__/useFlagHandler.test.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import { renderHook } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
-import type { FlagMessageNotifications } from '../useFlagHandler';
import { missingUseFlagHandlerParameterWarning, useFlagHandler } from '../useFlagHandler';
import { ChatProvider } from '../../../../context/ChatContext';
@@ -25,7 +24,6 @@ const mouseEventMock = fromPartial({
async function renderUseHandleFlagHook(
message?: LocalMessage,
- notificationOpts?: FlagMessageNotifications,
channelStateContextValue?: Record,
) {
const client = await getTestClientWithUser(alice);
@@ -43,7 +41,7 @@ async function renderUseHandleFlagHook(
);
- const { result } = renderHook(() => useFlagHandler(message, notificationOpts), {
+ const { result } = renderHook(() => useFlagHandler(message), {
wrapper,
});
return result.current;
@@ -62,59 +60,19 @@ describe('useHandleFlag custom hook', () => {
expect(consoleWarnSpy).toHaveBeenCalledWith(missingUseFlagHandlerParameterWarning);
});
- it('should allow to flag a message and notify with custom success notification when it is successful', async () => {
+ it('should allow to flag a message when it is successful', async () => {
const message = generateMessage();
- const notify = vi.fn();
flagMessage.mockImplementationOnce(() => Promise.resolve());
- const messageFlaggedNotification = 'Message flagged!';
- const getSuccessNotification = vi.fn(() => messageFlaggedNotification);
- const handleFlag = await renderUseHandleFlagHook(message, {
- getSuccessNotification,
- notify,
- });
+ const handleFlag = await renderUseHandleFlagHook(message);
await handleFlag(mouseEventMock);
expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(notify).toHaveBeenCalledWith(messageFlaggedNotification, 'success');
});
- it('should allow to flag a message and notify with default success notification when it is successful', async () => {
+ it('should throw when flagging fails', async () => {
const message = generateMessage();
- const notify = vi.fn();
- flagMessage.mockImplementationOnce(() => Promise.resolve());
- const defaultSuccessNotification = 'Message has been successfully flagged';
- const handleFlag = await renderUseHandleFlagHook(message, {
- notify,
- });
- await handleFlag(mouseEventMock);
- expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(notify).toHaveBeenCalledWith(defaultSuccessNotification, 'success');
- });
-
- it('should allow to flag a message and notify with custom error message when it fails', async () => {
- const message = generateMessage();
- const notify = vi.fn();
- flagMessage.mockImplementationOnce(() => Promise.reject());
- const messageFlagFailedNotification = 'Message flagged failed!';
- const getErrorNotification = vi.fn(() => messageFlagFailedNotification);
- const handleFlag = await renderUseHandleFlagHook(message, {
- getErrorNotification,
- notify,
- });
- await handleFlag(mouseEventMock);
- expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(notify).toHaveBeenCalledWith(messageFlagFailedNotification, 'error');
- });
-
- it('should allow to flag a user and notify with default error message when it fails', async () => {
- const message = generateMessage();
- const notify = vi.fn();
- flagMessage.mockImplementationOnce(() => Promise.reject());
- const defaultFlagMessageFailedNotification = 'Error adding flag';
- const handleFlag = await renderUseHandleFlagHook(message, {
- notify,
- });
- await handleFlag(mouseEventMock);
+ flagMessage.mockImplementationOnce(() => Promise.reject(new Error('flag failed')));
+ const handleFlag = await renderUseHandleFlagHook(message);
+ await expect(handleFlag(mouseEventMock)).rejects.toThrow('flag failed');
expect(flagMessage).toHaveBeenCalledWith(message.id);
- expect(notify).toHaveBeenCalledWith(defaultFlagMessageFailedNotification, 'error');
});
});
diff --git a/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx b/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx
index d9d1856260..0f366cbf85 100644
--- a/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx
+++ b/src/components/Message/hooks/__tests__/useMuteHandler.test.tsx
@@ -3,7 +3,6 @@ import { renderHook } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { missingUseMuteHandlerParamsWarning, useMuteHandler } from '../useMuteHandler';
-import type { MuteUserNotifications } from '../useMuteHandler';
import { ChannelStateProvider } from '../../../../context/ChannelStateContext';
import { ChatProvider } from '../../../../context/ChatContext';
@@ -28,7 +27,6 @@ const mouseEventMock = fromPartial({
async function renderUseHandleMuteHook(
message: LocalMessage | undefined = generateMessage() as MessageResponse & LocalMessage,
- notificationOpts?: MuteUserNotifications,
channelStateContextValue?: Partial & Record,
) {
const client = await getTestClientWithUser(alice);
@@ -49,7 +47,7 @@ async function renderUseHandleMuteHook(
);
- const { result } = renderHook(() => useMuteHandler(message, notificationOpts), {
+ const { result } = renderHook(() => useMuteHandler(message), {
wrapper,
});
return result.current;
@@ -69,134 +67,38 @@ describe('useHandleMute custom hook', () => {
expect(consoleWarnSpy).toHaveBeenCalledWith(missingUseMuteHandlerParamsWarning);
});
- it('should allow to mute a user and notify with custom success notification when it is successful', async () => {
+ it('should allow to mute a user when it is successful', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- const userMutedNotification = 'User muted!';
- const getMuteUserSuccessNotification = vi.fn(() => userMutedNotification);
- const handleMute = await renderUseHandleMuteHook(message, {
- getSuccessNotification: getMuteUserSuccessNotification,
- notify,
- });
+ const handleMute = await renderUseHandleMuteHook(message);
await handleMute(mouseEventMock);
expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(userMutedNotification, 'success');
});
- it('should allow to mute a user and notify with default success notification when it is successful', async () => {
+ it('should throw when muting a user fails', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- // The key for the default success message, defined in the implementation
- const defaultSuccessMessage = '{{ user }} has been muted';
- const notify = vi.fn();
- const handleMute = await renderUseHandleMuteHook(message, { notify });
- await handleMute(mouseEventMock);
+ muteUser.mockImplementationOnce(() => Promise.reject(new Error('mute failed')));
+ const handleMute = await renderUseHandleMuteHook(message);
+ await expect(handleMute(mouseEventMock)).rejects.toThrow('mute failed');
expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(defaultSuccessMessage, 'success');
});
- it('should allow to mute a user and notify with custom error message when muting a user fails', async () => {
+ it('should allow to unmute a user when it is successful', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- muteUser.mockImplementationOnce(() => Promise.reject());
- const userMutedFailNotification = 'User mute failed!';
- const getErrorNotification = vi.fn(() => userMutedFailNotification);
+ unmuteUser.mockImplementationOnce(() => Promise.resolve());
const handleMute = await renderUseHandleMuteHook(message, {
- getErrorNotification,
- notify,
+ mutes: [fromPartial({ target: { id: bob.id } })],
});
await handleMute(mouseEventMock);
- expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(userMutedFailNotification, 'error');
+ expect(unmuteUser).toHaveBeenCalledWith(bob.id);
});
- it('should allow to mute a user and notify with default error message when muting a user fails', async () => {
+ it('should throw when unmuting a user fails', async () => {
const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- muteUser.mockImplementationOnce(() => Promise.reject());
- // Defined in the implementation
- const defaultFailNotification = 'Error muting a user ...';
+ unmuteUser.mockImplementationOnce(() => Promise.reject(new Error('unmute failed')));
const handleMute = await renderUseHandleMuteHook(message, {
- notify,
+ mutes: [fromPartial({ target: { id: bob.id } })],
});
- await handleMute(mouseEventMock);
- expect(muteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(defaultFailNotification, 'error');
- });
-
- it('should allow to unmute a user and notify with custom success notification when it is successful', async () => {
- const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- unmuteUser.mockImplementationOnce(() => Promise.resolve());
- const userUnmutedNotification = 'User unmuted!';
- const getSuccessNotification = vi.fn(() => userUnmutedNotification);
- const handleMute = await renderUseHandleMuteHook(
- message,
- {
- getSuccessNotification,
- notify,
- },
- { mutes: [fromPartial({ target: { id: bob.id } })] },
- );
- await handleMute(mouseEventMock);
- expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(userUnmutedNotification, 'success');
- });
-
- it('should allow to unmute a user and notify with default success notification when it is successful', async () => {
- const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- unmuteUser.mockImplementationOnce(() => Promise.resolve());
- // Defined in the implementation
- const defaultSuccessNotification = '{{ user }} has been unmuted';
- const handleMute = await renderUseHandleMuteHook(
- message,
- {
- notify,
- },
- { mutes: [fromPartial({ target: { id: bob.id } })] },
- );
- await handleMute(mouseEventMock);
- expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(defaultSuccessNotification, 'success');
- });
-
- it('should allow to unmute a user and notify with custom error message when it fails', async () => {
- const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- unmuteUser.mockImplementationOnce(() => Promise.reject());
- const userMutedFailNotification = 'User muted failed!';
- const getErrorNotification = vi.fn(() => userMutedFailNotification);
- const handleMute = await renderUseHandleMuteHook(
- message,
- {
- getErrorNotification,
- notify,
- },
- { mutes: [fromPartial({ target: { id: bob.id } })] },
- );
-
- await handleMute(mouseEventMock);
- expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(userMutedFailNotification, 'error');
- });
-
- it('should allow to unmute a user and notify with default error message when it fails', async () => {
- const message = generateMessage({ user: bob }) as MessageResponse & LocalMessage;
- const notify = vi.fn();
- unmuteUser.mockImplementationOnce(() => Promise.reject());
- // Defined in the implementation
- const defaultFailNotification = 'Error unmuting a user ...';
- const handleMute = await renderUseHandleMuteHook(
- message,
- {
- notify,
- },
- {
- mutes: [fromPartial({ target: { id: bob.id } })],
- },
- );
- await handleMute(mouseEventMock);
+ await expect(handleMute(mouseEventMock)).rejects.toThrow('unmute failed');
expect(unmuteUser).toHaveBeenCalledWith(bob.id);
- expect(notify).toHaveBeenCalledWith(defaultFailNotification, 'error');
});
});
diff --git a/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx b/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx
index 1afe226f23..542457bcdf 100644
--- a/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx
+++ b/src/components/Message/hooks/__tests__/userMarkUnreadHandler.test.tsx
@@ -2,7 +2,6 @@ import React from 'react';
import { renderHook } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { useMarkUnreadHandler } from '../useMarkUnreadHandler';
-import type { MarkUnreadHandlerNotifications } from '../useMarkUnreadHandler';
import { ChannelStateProvider, TranslationProvider } from '../../../../context';
import type { TranslationContextValue } from '../../../../context';
import {
@@ -14,24 +13,13 @@ import type { LocalMessage } from 'stream-chat';
vi.spyOn(console, 'warn').mockImplementation(() => null);
-const customSuccessString = 'Custom Success';
-const customErrorString = 'Custom Error';
-const noop = () => null;
-const generateSuccessString = () => customSuccessString;
-const generateErrorString = () => customErrorString;
const event = fromPartial({ preventDefault: vi.fn() });
const t = ((str: string) => str) as TranslationContextValue['t'];
const message = generateMessage() as unknown as LocalMessage;
-const notifications: MarkUnreadHandlerNotifications = {
- notify: vi.fn(),
-};
const channel = fromPartial<{ markUnread: ReturnType }>({
markUnread: vi.fn(),
});
-function renderUseMarkUnreadHandlerHook({
- message,
- notifications,
-}: { message?: LocalMessage; notifications?: MarkUnreadHandlerNotifications } = {}) {
+function renderUseMarkUnreadHandlerHook({ message }: { message?: LocalMessage } = {}) {
const wrapper = ({ children }: { children?: React.ReactNode }) => (
);
- const { result } = renderHook(() => useMarkUnreadHandler(message, notifications), {
+ const { result } = renderHook(() => useMarkUnreadHandler(message), {
wrapper,
});
return result.current;
@@ -69,75 +57,14 @@ describe('useMarkUnreadHandler', () => {
expect.objectContaining({ message_id: message.id }),
);
});
- it('does not register success notification if getSuccessNotification is not available', async () => {
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({ message, notifications });
- await handleMarkUnread(event);
- expect(notifications.notify).not.toHaveBeenCalled();
- });
- it('does not register success notification if getSuccessNotification does not generate one', async () => {
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({
- message,
- notifications: { ...notifications, getSuccessNotification: noop },
- });
- await handleMarkUnread(event);
- expect(notifications.notify).not.toHaveBeenCalled();
- });
- it('registers the success notification if getSuccessNotification generates one', async () => {
- const notificationsWithSuccess = {
- getSuccessNotification: generateSuccessString,
- notify: vi.fn(),
- };
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({
- message,
- notifications: notificationsWithSuccess,
- });
- await handleMarkUnread(event);
- expect(notificationsWithSuccess.notify).toHaveBeenCalledWith(
- customSuccessString,
- 'success',
- );
- });
-
- it('registers the default error notification if getErrorNotification is missing', async () => {
- channel.markUnread.mockRejectedValueOnce(undefined);
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({
- message,
- notifications,
- });
- await handleMarkUnread(event);
- expect(notifications.notify).toHaveBeenCalledWith(
- 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
- 'error',
- );
- });
- it('registers the custom error notification if available getErrorNotification generates one', async () => {
- channel.markUnread.mockRejectedValueOnce(undefined);
- const notificationsWithError = {
- getErrorNotification: generateErrorString,
- notify: vi.fn(),
- };
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({
- message,
- notifications: notificationsWithError,
- });
+ it('completes without throwing on successful mark unread', async () => {
+ const handleMarkUnread = renderUseMarkUnreadHandlerHook({ message });
await handleMarkUnread(event);
- expect(notificationsWithError.notify).toHaveBeenCalledWith(
- customErrorString,
- 'error',
- );
});
- it('does not register the custom error notification if available getErrorNotification does not generate one', async () => {
- channel.markUnread.mockRejectedValueOnce(undefined);
- const notificationsWithError = {
- getErrorNotification: noop,
- notify: vi.fn(),
- };
- const handleMarkUnread = renderUseMarkUnreadHandlerHook({
- message,
- notifications: notificationsWithError,
- });
- await handleMarkUnread(event);
- expect(notificationsWithError.notify).not.toHaveBeenCalled();
+ it('throws the default error message if mark unread fails', async () => {
+ channel.markUnread.mockRejectedValueOnce(new Error('mark unread failed'));
+ const handleMarkUnread = renderUseMarkUnreadHandlerHook({ message });
+ await expect(handleMarkUnread(event)).rejects.toThrow('mark unread failed');
});
});
diff --git a/src/components/Message/hooks/useDeleteHandler.ts b/src/components/Message/hooks/useDeleteHandler.ts
index a3fd60d3ce..e17ff8cec2 100644
--- a/src/components/Message/hooks/useDeleteHandler.ts
+++ b/src/components/Message/hooks/useDeleteHandler.ts
@@ -1,27 +1,17 @@
-import { isNetworkSendFailure, validateAndGetMessage } from '../utils';
+import { isNetworkSendFailure } from '../utils';
import { useChannelActionContext } from '../../../context/ChannelActionContext';
import { useChatContext } from '../../../context/ChatContext';
-import { useTranslationContext } from '../../../context/TranslationContext';
import type { DeleteMessageOptions, LocalMessage } from 'stream-chat';
import type { MessageContextValue } from '../../../context';
-export type DeleteMessageNotifications = {
- getErrorNotification?: (message: LocalMessage) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
export const useDeleteHandler = (
message?: LocalMessage,
- notifications: DeleteMessageNotifications = {},
): MessageContextValue['handleDelete'] => {
- const { getErrorNotification, notify } = notifications;
-
const { deleteMessage, removeMessage, updateMessage } =
useChannelActionContext('useDeleteHandler');
const { client } = useChatContext('useDeleteHandler');
- const { t } = useTranslationContext('useDeleteHandler');
return async (options?: DeleteMessageOptions) => {
if (!message) {
@@ -37,15 +27,7 @@ export const useDeleteHandler = (
return;
}
- try {
- const deletedMessage = await deleteMessage(message, options);
- updateMessage(deletedMessage);
- } catch (e) {
- const errorMessage =
- getErrorNotification && validateAndGetMessage(getErrorNotification, [message]);
-
- if (notify) notify(errorMessage || t('Error deleting message'), 'error');
- throw e;
- }
+ const deletedMessage = await deleteMessage(message, options);
+ updateMessage(deletedMessage);
};
};
diff --git a/src/components/Message/hooks/useFlagHandler.ts b/src/components/Message/hooks/useFlagHandler.ts
index f8b7fda838..b5cd472a8b 100644
--- a/src/components/Message/hooks/useFlagHandler.ts
+++ b/src/components/Message/hooks/useFlagHandler.ts
@@ -1,5 +1,3 @@
-import { validateAndGetMessage } from '../utils';
-
import { useChatContext } from '../../../context/ChatContext';
import { useTranslationContext } from '../../../context/TranslationContext';
@@ -9,46 +7,18 @@ import type { ReactEventHandler } from '../types';
export const missingUseFlagHandlerParameterWarning =
'useFlagHandler was called but it is missing one or more necessary parameters.';
-export type FlagMessageNotifications = {
- getErrorNotification?: (message: LocalMessage) => string;
- getSuccessNotification?: (message: LocalMessage) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
-export const useFlagHandler = (
- message?: LocalMessage,
- notifications: FlagMessageNotifications = {},
-): ReactEventHandler => {
+export const useFlagHandler = (message?: LocalMessage): ReactEventHandler => {
const { client } = useChatContext('useFlagHandler');
const { t } = useTranslationContext('useFlagHandler');
return async (event) => {
event.preventDefault();
- const { getErrorNotification, getSuccessNotification, notify } = notifications;
-
- if (!client || !t || !notify || !message?.id) {
+ if (!client || !t || !message?.id) {
console.warn(missingUseFlagHandlerParameterWarning);
return;
}
- if (client.user?.banned) {
- return notify(t('Error adding flag'), 'error');
- }
-
- try {
- await client.flagMessage(message.id);
-
- const successMessage =
- getSuccessNotification &&
- validateAndGetMessage(getSuccessNotification, [message]);
-
- notify(successMessage || t('Message has been successfully flagged'), 'success');
- } catch (e) {
- const errorMessage =
- getErrorNotification && validateAndGetMessage(getErrorNotification, [message]);
-
- notify(errorMessage || t('Error adding flag'), 'error');
- }
+ await client.flagMessage(message.id);
};
};
diff --git a/src/components/Message/hooks/useMarkUnreadHandler.ts b/src/components/Message/hooks/useMarkUnreadHandler.ts
index b62d3e5829..4d44bb0664 100644
--- a/src/components/Message/hooks/useMarkUnreadHandler.ts
+++ b/src/components/Message/hooks/useMarkUnreadHandler.ts
@@ -1,23 +1,10 @@
-import { validateAndGetMessage } from '../utils';
-import { useChannelStateContext, useTranslationContext } from '../../../context';
+import { useChannelStateContext } from '../../../context';
import type { LocalMessage } from 'stream-chat';
import type { ReactEventHandler } from '../types';
-export type MarkUnreadHandlerNotifications = {
- getErrorNotification?: (message: LocalMessage) => string;
- getSuccessNotification?: (message: LocalMessage) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
-export const useMarkUnreadHandler = (
- message?: LocalMessage,
- notifications: MarkUnreadHandlerNotifications = {},
-): ReactEventHandler => {
- const { getErrorNotification, getSuccessNotification, notify } = notifications;
-
+export const useMarkUnreadHandler = (message?: LocalMessage): ReactEventHandler => {
const { channel } = useChannelStateContext('useMarkUnreadHandler');
- const { t } = useTranslationContext('useMarkUnreadHandler');
return async (event) => {
event.preventDefault();
@@ -26,25 +13,6 @@ export const useMarkUnreadHandler = (
return;
}
- try {
- await channel.markUnread({ message_id: message.id });
- if (!notify) return;
- const successMessage =
- getSuccessNotification &&
- validateAndGetMessage(getSuccessNotification, [message]);
- if (successMessage) notify(successMessage, 'success');
- } catch (e) {
- if (!notify) return;
- const errorMessage =
- getErrorNotification && validateAndGetMessage(getErrorNotification, [message]);
- if (getErrorNotification && !errorMessage) return;
- notify(
- errorMessage ||
- t(
- 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
- ),
- 'error',
- );
- }
+ await channel.markUnread({ message_id: message.id });
};
};
diff --git a/src/components/Message/hooks/useMuteHandler.ts b/src/components/Message/hooks/useMuteHandler.ts
index 470d1fcbea..c47a9efd8a 100644
--- a/src/components/Message/hooks/useMuteHandler.ts
+++ b/src/components/Message/hooks/useMuteHandler.ts
@@ -1,26 +1,17 @@
-import { isUserMuted, validateAndGetMessage } from '../utils';
+import { isUserMuted } from '../utils';
import { useChannelStateContext } from '../../../context/ChannelStateContext';
import { useChatContext } from '../../../context/ChatContext';
import { useTranslationContext } from '../../../context/TranslationContext';
-import type { LocalMessage, UserResponse } from 'stream-chat';
+import type { LocalMessage } from 'stream-chat';
import type { ReactEventHandler } from '../types';
export const missingUseMuteHandlerParamsWarning =
'useMuteHandler was called but it is missing one or more necessary parameter.';
-export type MuteUserNotifications = {
- getErrorNotification?: (user: UserResponse) => string;
- getSuccessNotification?: (user: UserResponse) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
-export const useMuteHandler = (
- message?: LocalMessage,
- notifications: MuteUserNotifications = {},
-): ReactEventHandler => {
+export const useMuteHandler = (message?: LocalMessage): ReactEventHandler => {
const { mutes } = useChannelStateContext('useMuteHandler');
const { client } = useChatContext('useMuteHandler');
const { t } = useTranslationContext('useMuteHandler');
@@ -28,61 +19,15 @@ export const useMuteHandler = (
return async (event) => {
event.preventDefault();
- const { getErrorNotification, getSuccessNotification, notify } = notifications;
-
- if (!t || !message?.user || !notify || !client) {
+ if (!t || !message?.user || !client) {
console.warn(missingUseMuteHandlerParamsWarning);
return;
}
if (!isUserMuted(message, mutes)) {
- try {
- await client.muteUser(message.user.id);
-
- const successMessage =
- getSuccessNotification &&
- validateAndGetMessage(getSuccessNotification, [message.user]);
-
- notify(
- successMessage ||
- t('{{ user }} has been muted', {
- user: message.user.name || message.user.id,
- }),
- 'success',
- );
- } catch (e) {
- const errorMessage =
- getErrorNotification &&
- validateAndGetMessage(getErrorNotification, [message.user]);
-
- notify(errorMessage || t('Error muting a user ...'), 'error');
- }
+ await client.muteUser(message.user.id);
} else {
- try {
- await client.unmuteUser(message.user.id);
-
- const fallbackMessage = t('{{ user }} has been unmuted', {
- user: message.user.name || message.user.id,
- });
-
- const successMessage =
- (getSuccessNotification &&
- validateAndGetMessage(getSuccessNotification, [message.user])) ||
- fallbackMessage;
-
- if (typeof successMessage === 'string') {
- notify(successMessage, 'success');
- }
- } catch (e) {
- const errorMessage =
- (getErrorNotification &&
- validateAndGetMessage(getErrorNotification, [message.user])) ||
- t('Error unmuting a user ...');
-
- if (typeof errorMessage === 'string') {
- notify(errorMessage, 'error');
- }
- }
+ await client.unmuteUser(message.user.id);
}
};
};
diff --git a/src/components/Message/hooks/usePinHandler.ts b/src/components/Message/hooks/usePinHandler.ts
index 7cc7bc4d37..6e9094eae2 100644
--- a/src/components/Message/hooks/usePinHandler.ts
+++ b/src/components/Message/hooks/usePinHandler.ts
@@ -1,28 +1,14 @@
-import { validateAndGetMessage } from '../utils';
-
import { useChannelActionContext } from '../../../context/ChannelActionContext';
import { useChannelStateContext } from '../../../context/ChannelStateContext';
import { useChatContext } from '../../../context/ChatContext';
-import { useTranslationContext } from '../../../context/TranslationContext';
import type { LocalMessage } from 'stream-chat';
import type { ReactEventHandler } from '../types';
-export type PinMessageNotifications = {
- getErrorNotification?: (message: LocalMessage) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
-export const usePinHandler = (
- message: LocalMessage,
- notifications: PinMessageNotifications = {},
-) => {
- const { getErrorNotification, notify } = notifications;
-
+export const usePinHandler = (message: LocalMessage) => {
const { updateMessage } = useChannelActionContext('usePinHandler');
const { channelCapabilities = {} } = useChannelStateContext('usePinHandler');
const { client } = useChatContext('usePinHandler');
- const { t } = useTranslationContext('usePinHandler');
const canPin = !!channelCapabilities['pin-message'];
@@ -44,10 +30,6 @@ export const usePinHandler = (
await client.pinMessage(message);
} catch (e) {
- const errorMessage =
- getErrorNotification && validateAndGetMessage(getErrorNotification, [message]);
-
- if (notify) notify(errorMessage || t('Error pinning message'), 'error');
updateMessage(message);
}
} else {
@@ -64,10 +46,6 @@ export const usePinHandler = (
await client.unpinMessage(message);
} catch (e) {
- const errorMessage =
- getErrorNotification && validateAndGetMessage(getErrorNotification, [message]);
-
- if (notify) notify(errorMessage || t('Error removing message pin'), 'error');
updateMessage(message);
}
}
diff --git a/src/components/Message/hooks/useReactionsFetcher.ts b/src/components/Message/hooks/useReactionsFetcher.ts
index 588b798bff..aac3d7c6eb 100644
--- a/src/components/Message/hooks/useReactionsFetcher.ts
+++ b/src/components/Message/hooks/useReactionsFetcher.ts
@@ -1,4 +1,4 @@
-import { useChatContext, useTranslationContext } from '../../../context';
+import { useChatContext } from '../../../context';
import { useStableCallback } from '../../../utils/useStableCallback';
import type {
LocalMessage,
@@ -10,28 +10,12 @@ import type { ReactionType } from '../../Reactions/types';
export const MAX_MESSAGE_REACTIONS_TO_FETCH = 1000;
-type FetchMessageReactionsNotifications = {
- getErrorNotification?: (message: LocalMessage) => string;
- notify?: (notificationText: string, type: 'success' | 'error') => void;
-};
-
-export function useReactionsFetcher(
- message: LocalMessage,
- notifications: FetchMessageReactionsNotifications = {},
-) {
+export function useReactionsFetcher(message: LocalMessage) {
const { client } = useChatContext('useRectionsFetcher');
- const { t } = useTranslationContext('useReactionFetcher');
- const { getErrorNotification, notify } = notifications;
- return useStableCallback(async (reactionType?: ReactionType, sort?: ReactionSort) => {
- try {
- return await fetchMessageReactions(client, message.id, reactionType, sort);
- } catch (e) {
- const errorMessage = getErrorNotification?.(message);
- notify?.(errorMessage || t('Error fetching reactions'), 'error');
- throw e;
- }
- });
+ return useStableCallback((reactionType?: ReactionType, sort?: ReactionSort) =>
+ fetchMessageReactions(client, message.id, reactionType, sort),
+ );
}
async function fetchMessageReactions(
diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts
index 93439a88ef..ef1b2947f4 100644
--- a/src/components/Message/types.ts
+++ b/src/components/Message/types.ts
@@ -29,24 +29,6 @@ export type MessageProps = {
// todo: remove
/** Override the default formatting of the date. This is a function that has access to the original date object, returns a string */
formatDate?: (date: Date) => string;
- /** Function that returns the notification text to be displayed when a delete message request fails */
- getDeleteMessageErrorNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when loading message reactions fails */
- getFetchReactionsErrorNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when a flag message request fails */
- getFlagMessageErrorNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when a flag message request succeeds */
- getFlagMessageSuccessNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when mark channel messages unread request fails */
- getMarkMessageUnreadErrorNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when mark channel messages unread request succeeds */
- getMarkMessageUnreadSuccessNotification?: (message: LocalMessage) => string;
- /** Function that returns the notification text to be displayed when a mute user request fails */
- getMuteUserErrorNotification?: (user: UserResponse) => string;
- /** Function that returns the notification text to be displayed when a mute user request succeeds */
- getMuteUserSuccessNotification?: (user: UserResponse) => string;
- /** Function that returns the notification text to be displayed when a pin message request fails */
- getPinMessageErrorNotification?: (message: LocalMessage) => string;
/** A list of styles to apply to this message, i.e. top, bottom, single */
groupStyles?: GroupStyle[];
/** Whether to highlight and focus the message on load */
diff --git a/src/components/MessageActions/MessageActions.defaults.tsx b/src/components/MessageActions/MessageActions.defaults.tsx
index 53354e974b..d19d524a51 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 {
@@ -52,6 +52,19 @@ import { DeleteMessageAlert } from './DeleteMessageAlert';
const msgActionsBoxButtonClassName =
'str-chat__message-actions-list-item-button' as const;
+const getErrorMessage = (error: unknown, fallback: string) =>
+ error instanceof Error && error.message ? error.message : fallback;
+
+const getNotificationError = (error: unknown): Error | undefined => {
+ if (error instanceof Error) return error;
+ if (typeof error === 'string') return new Error(error);
+ if (error && typeof error === 'object' && 'message' in error) {
+ const message = error.message;
+ if (typeof message === 'string') return new Error(message);
+ }
+ return undefined;
+};
+
const DefaultMessageActionComponents = {
dropdown: {
ThreadReply() {
@@ -110,6 +123,7 @@ const DefaultMessageActionComponents = {
Pin() {
const { closeMenu } = useContextMenuContext();
const { handlePin, message } = useMessageContext();
+ const { addNotification } = useNotificationApi();
const { t } = useTranslationContext();
const isPinned = !!message.pinned;
return (
@@ -117,8 +131,33 @@ const DefaultMessageActionComponents = {
aria-label={isPinned ? t('aria/Unpin Message') : t('aria/Pin Message')}
className={msgActionsBoxButtonClassName}
Icon={isPinned ? IconUnpin : IconPin}
- onClick={(event) => {
- handlePin(event);
+ onClick={async (event) => {
+ try {
+ await handlePin(event);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: isPinned ? t('Message unpinned') : t('Message pinned'),
+ severity: 'success',
+ type: isPinned ? 'api:message:unpin:success' : 'api:message:pin:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(
+ error,
+ isPinned ? t('Error removing message pin') : t('Error pinning message'),
+ ),
+ severity: 'error',
+ type: isPinned ? 'api:message:unpin:failed' : 'api:message:pin:failed',
+ });
+ }
closeMenu();
}}
>
@@ -187,7 +226,8 @@ const DefaultMessageActionComponents = {
},
MarkUnread() {
const { closeMenu } = useContextMenuContext();
- const { handleMarkUnread } = useMessageContext();
+ const { handleMarkUnread, message } = useMessageContext();
+ const { addNotification } = useNotificationApi();
const { t } = useTranslationContext();
return (
@@ -195,8 +235,35 @@ const DefaultMessageActionComponents = {
aria-label={t('aria/Mark Message Unread')}
className={msgActionsBoxButtonClassName}
Icon={IconNotification}
- onClick={(event) => {
- handleMarkUnread(event);
+ onClick={async (event) => {
+ try {
+ await handleMarkUnread(event);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Message marked as unread'),
+ severity: 'success',
+ type: 'api:message:markUnread:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(
+ error,
+ t(
+ 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
+ ),
+ ),
+ severity: 'error',
+ type: 'api:message:markUnread:failed',
+ });
+ }
closeMenu();
}}
>
@@ -207,6 +274,7 @@ const DefaultMessageActionComponents = {
RemindMe() {
const { closeMenu, openSubmenu } = useContextMenuContext();
const { client } = useChatContext();
+ const { addNotification } = useNotificationApi();
const { t } = useTranslationContext();
const { message } = useMessageContext();
const reminder = useMessageReminder(message.id);
@@ -220,10 +288,33 @@ const DefaultMessageActionComponents = {
className={msgActionsBoxButtonClassName}
hasSubMenu={!reminder}
Icon={reminder ? IconBellOff : IconBell}
- onClick={() => {
+ onClick={async () => {
if (reminder) {
- client.reminders.deleteReminder(reminder.id);
- closeMenu();
+ try {
+ await client.reminders.deleteReminder(reminder.id);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Remove reminder'),
+ severity: 'success',
+ type: 'api:message:reminder:delete:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, 'Error removing reminder'),
+ severity: 'error',
+ type: 'api:message:reminder:delete:failed',
+ });
+ } finally {
+ closeMenu();
+ }
} else {
openSubmenu({
Header: RemindMeSubmenuHeader,
@@ -239,6 +330,7 @@ const DefaultMessageActionComponents = {
SaveForLater() {
const { closeMenu } = useContextMenuContext();
const { client } = useChatContext();
+ const { addNotification } = useNotificationApi();
const { message } = useMessageContext();
const { t } = useTranslationContext();
const reminder = useMessageReminder(message.id);
@@ -253,10 +345,52 @@ const DefaultMessageActionComponents = {
}
className={msgActionsBoxButtonClassName}
Icon={reminder ? IconUnsave : IconSave}
- onClick={() => {
- if (reminder) client.reminders.deleteReminder(reminder.id);
- else client.reminders.createReminder({ messageId: message.id });
- closeMenu();
+ onClick={async () => {
+ try {
+ if (reminder) {
+ await client.reminders.deleteReminder(reminder.id);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Remove save for later'),
+ severity: 'success',
+ type: 'api:message:saveForLater:delete:success',
+ });
+ } else {
+ await client.reminders.createReminder({ messageId: message.id });
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Saved for later'),
+ severity: 'success',
+ type: 'api:message:saveForLater:create:success',
+ });
+ }
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(
+ error,
+ reminder
+ ? 'Error removing message from saved for later'
+ : 'Error saving message for later',
+ ),
+ severity: 'error',
+ type: reminder
+ ? 'api:message:saveForLater:delete:failed'
+ : 'api:message:saveForLater:create:failed',
+ });
+ } finally {
+ closeMenu();
+ }
}}
>
{reminder ? t('Remove save for later') : t('Save for later')}
@@ -265,7 +399,8 @@ const DefaultMessageActionComponents = {
},
Flag() {
const { closeMenu } = useContextMenuContext();
- const { handleFlag } = useMessageContext();
+ const { handleFlag, message } = useMessageContext();
+ const { addNotification } = useNotificationApi();
const { t } = useTranslationContext();
return (
@@ -273,8 +408,30 @@ const DefaultMessageActionComponents = {
aria-label={t('aria/Flag Message')}
className={msgActionsBoxButtonClassName}
Icon={IconFlag}
- onClick={(event) => {
- handleFlag(event);
+ onClick={async (event) => {
+ try {
+ await handleFlag(event);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Message has been successfully flagged'),
+ severity: 'success',
+ type: 'api:message:flag:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, t('Error adding flag')),
+ severity: 'error',
+ type: 'api:message:flag:failed',
+ });
+ }
closeMenu();
}}
>
@@ -285,6 +442,7 @@ const DefaultMessageActionComponents = {
Mute() {
const { closeMenu } = useContextMenuContext();
const { handleMute, message } = useMessageContext();
+ const { addNotification } = useNotificationApi();
const { mutes } = useChatContext();
const { t } = useTranslationContext();
@@ -294,8 +452,39 @@ const DefaultMessageActionComponents = {
aria-label={isMuted ? t('aria/Unmute User') : t('aria/Mute User')}
className={msgActionsBoxButtonClassName}
Icon={isMuted ? IconAudio : IconMute}
- onClick={(event) => {
- handleMute(event);
+ onClick={async (event) => {
+ try {
+ await handleMute(event);
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: isMuted
+ ? t('{{ user }} has been unmuted', {
+ user: message.user?.name || message.user?.id,
+ })
+ : t('{{ user }} has been muted', {
+ user: message.user?.name || message.user?.id,
+ }),
+ severity: 'success',
+ type: isMuted ? 'api:user:unmute:success' : 'api:user:mute:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(
+ error,
+ isMuted ? t('Error unmuting a user ...') : t('Error muting a user ...'),
+ ),
+ severity: 'error',
+ type: isMuted ? 'api:user:unmute:failed' : 'api:user:mute:failed',
+ });
+ }
closeMenu();
}}
>
@@ -305,10 +494,9 @@ const DefaultMessageActionComponents = {
},
Delete() {
const { closeMenu } = useContextMenuContext();
- const { client } = useChatContext();
+ const { addNotification } = useNotificationApi();
const { Modal = GlobalModal } = useComponentContext();
- const { handleDelete } = useMessageContext();
- const panel = useNotificationTarget();
+ const { handleDelete, message } = useMessageContext();
const { t } = useTranslationContext();
const [openModal, setOpenModal] = useState(false);
@@ -334,15 +522,26 @@ const DefaultMessageActionComponents = {
onDelete={async () => {
try {
await handleDelete();
- client.notifications.addSuccess({
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
message: t('Message deleted'),
- options: {
- tags: addNotificationTargetTag(panel),
+ severity: 'success',
+ type: 'api:message:delete:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
},
- origin: { emitter: 'MessageActions' },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, t('Error deleting message')),
+ severity: 'error',
+ type: 'api:message:delete:failed',
});
- } catch {
- // Error notification is handled by useDeleteHandler.
} finally {
setOpenModal(false);
closeMenu();
diff --git a/src/components/MessageActions/RemindMeSubmenu.tsx b/src/components/MessageActions/RemindMeSubmenu.tsx
index 804a04b2a8..1614f34f4b 100644
--- a/src/components/MessageActions/RemindMeSubmenu.tsx
+++ b/src/components/MessageActions/RemindMeSubmenu.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { useChatContext, useMessageContext, useTranslationContext } from '../../context';
+import { useNotificationApi } from '../Notifications';
import {
ContextMenuBackButton,
ContextMenuButton,
@@ -8,6 +9,19 @@ import {
} from '../Dialog';
import { IconChevronLeft } from '../Icons';
+const getErrorMessage = (error: unknown, fallback: string) =>
+ error instanceof Error && error.message ? error.message : fallback;
+
+const getNotificationError = (error: unknown): Error | undefined => {
+ if (error instanceof Error) return error;
+ if (typeof error === 'string') return new Error(error);
+ if (error && typeof error === 'object' && 'message' in error) {
+ const message = error.message;
+ if (typeof message === 'string') return new Error(message);
+ }
+ return undefined;
+};
+
export const RemindMeSubmenuHeader = () => {
const { t } = useTranslationContext();
const { returnToParentMenu } = useContextMenuContext();
@@ -26,6 +40,7 @@ export const RemindMeSubmenu = () => {
const { client } = useChatContext();
const { message } = useMessageContext();
const { closeMenu } = useContextMenuContext();
+ const { addNotification } = useNotificationApi();
return (
{
{
- client.reminders.upsertReminder({
- messageId: message.id,
- remind_at: new Date(new Date().getTime() + offsetMs).toISOString(),
- });
- closeMenu();
+ onClick={async () => {
+ try {
+ await client.reminders.upsertReminder({
+ messageId: message.id,
+ remind_at: new Date(new Date().getTime() + offsetMs).toISOString(),
+ });
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ message: t('Reminder set'),
+ severity: 'success',
+ type: 'api:message:reminder:set:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: {
+ message,
+ },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, 'Error setting reminder'),
+ severity: 'error',
+ type: 'api:message:reminder:set:failed',
+ });
+ } finally {
+ closeMenu();
+ }
}}
>
{t('duration/Remind Me', { milliseconds: offsetMs })}
diff --git a/src/components/MessageActions/__tests__/MessageActions.test.tsx b/src/components/MessageActions/__tests__/MessageActions.test.tsx
index 70fdc98d0c..bd940f5711 100644
--- a/src/components/MessageActions/__tests__/MessageActions.test.tsx
+++ b/src/components/MessageActions/__tests__/MessageActions.test.tsx
@@ -18,6 +18,7 @@ import {
import {
generateChannel,
generateMessage,
+ generateReminderResponse,
generateUser,
getTestClientWithUser,
initClientWithChannels,
@@ -766,8 +767,7 @@ describe('', () => {
);
});
- it('should call custom success notification on successful mark unread', async () => {
- const getMarkMessageUnreadSuccessNotification = vi.fn();
+ it('should emit success notification on successful mark unread', async () => {
const {
channels: [channel],
client,
@@ -775,11 +775,12 @@ describe('', () => {
channelsData: [{ channel: { own_capabilities }, messages: [message], read }],
customUser: me,
});
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
await renderMarkUnreadUI({
channelProps: { channel },
chatProps: { client },
- messageProps: { getMarkMessageUnreadSuccessNotification, message },
+ messageProps: { message },
});
await toggleOpenMessageActions();
@@ -787,13 +788,15 @@ describe('', () => {
await fireEvent.click(screen.getByText(ACTION_TEXT));
});
- expect(getMarkMessageUnreadSuccessNotification).toHaveBeenCalledWith(
- expect.objectContaining(message),
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Message marked as unread',
+ options: expect.objectContaining({ severity: 'success' }),
+ }),
);
});
- it('should call custom error notification on failed mark unread', async () => {
- const getMarkMessageUnreadErrorNotification = vi.fn();
+ it('should emit error notification on failed mark unread', async () => {
const {
channels: [channel],
client,
@@ -802,11 +805,56 @@ describe('', () => {
customUser: me,
});
vi.spyOn(channel, 'markUnread').mockRejectedValueOnce(undefined!);
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
await renderMarkUnreadUI({
channelProps: { channel },
chatProps: { client },
- messageProps: { getMarkMessageUnreadErrorNotification, message },
+ messageProps: { message },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message:
+ 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
+ options: expect.objectContaining({ severity: 'error' }),
+ }),
+ );
+ });
+ });
+
+ describe('Remind me action', () => {
+ const ACTION_TEXT = 'Remind me';
+
+ const getMessageActionsWithReminders = () => [
+ 'delete',
+ 'edit',
+ 'flag',
+ 'mute',
+ 'pin',
+ 'quote',
+ 'react',
+ 'remindMe',
+ 'reply',
+ 'saveForLater',
+ ];
+
+ it('should emit success notification on successful remind me set', async () => {
+ const client = await getTestClientWithUser(alice);
+ vi.spyOn(client.reminders, 'upsertReminder').mockResolvedValueOnce(undefined!);
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ },
});
await toggleOpenMessageActions();
@@ -814,8 +862,219 @@ describe('', () => {
await fireEvent.click(screen.getByText(ACTION_TEXT));
});
- expect(getMarkMessageUnreadErrorNotification).toHaveBeenCalledWith(
- expect.objectContaining(message),
+ const remindMeOption = document.querySelector(
+ '.str-chat__message-actions-box__submenu .str-chat__message-actions-list-item-button',
+ ) as HTMLButtonElement;
+
+ await act(async () => {
+ await fireEvent.click(remindMeOption);
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Reminder set',
+ options: expect.objectContaining({
+ severity: 'success',
+ type: 'api:message:reminder:set:success',
+ }),
+ }),
+ );
+ });
+
+ it('should emit error notification on failed remind me set', async () => {
+ const client = await getTestClientWithUser(alice);
+ vi.spyOn(client.reminders, 'upsertReminder').mockRejectedValueOnce(
+ new Error('Boom'),
+ );
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(ACTION_TEXT));
+ });
+
+ const remindMeOption = document.querySelector(
+ '.str-chat__message-actions-box__submenu .str-chat__message-actions-list-item-button',
+ ) as HTMLButtonElement;
+
+ await act(async () => {
+ await fireEvent.click(remindMeOption);
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Boom',
+ options: expect.objectContaining({
+ severity: 'error',
+ type: 'api:message:reminder:set:failed',
+ }),
+ }),
+ );
+ });
+ });
+
+ describe('Save for later action', () => {
+ const SAVE_ACTION_TEXT = 'Save for later';
+ const REMOVE_ACTION_TEXT = 'Remove save for later';
+
+ const getMessageActionsWithReminders = () => [
+ 'delete',
+ 'edit',
+ 'flag',
+ 'mute',
+ 'pin',
+ 'quote',
+ 'react',
+ 'remindMe',
+ 'reply',
+ 'saveForLater',
+ ];
+
+ it('should emit success notification on successful save for later', async () => {
+ const client = await getTestClientWithUser(alice);
+ vi.spyOn(client.reminders, 'createReminder').mockResolvedValueOnce(undefined!);
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(SAVE_ACTION_TEXT));
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Saved for later',
+ options: expect.objectContaining({
+ severity: 'success',
+ type: 'api:message:saveForLater:create:success',
+ }),
+ }),
+ );
+ });
+
+ it('should emit error notification on failed save for later', async () => {
+ const client = await getTestClientWithUser(alice);
+ vi.spyOn(client.reminders, 'createReminder').mockRejectedValueOnce(
+ new Error('Save failed'),
+ );
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(SAVE_ACTION_TEXT));
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Save failed',
+ options: expect.objectContaining({
+ severity: 'error',
+ type: 'api:message:saveForLater:create:failed',
+ }),
+ }),
+ );
+ });
+
+ it('should emit success notification on successful remove save for later', async () => {
+ const client = await getTestClientWithUser(alice);
+ const message = generateMessage();
+ client.reminders.hydrateState([
+ generateMessage({
+ ...message,
+ reminder: generateReminderResponse({
+ data: { message_id: message.id },
+ }),
+ }),
+ ]);
+ vi.spyOn(client.reminders, 'deleteReminder').mockResolvedValueOnce(undefined!);
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ message,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(REMOVE_ACTION_TEXT));
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Remove save for later',
+ options: expect.objectContaining({
+ severity: 'success',
+ type: 'api:message:saveForLater:delete:success',
+ }),
+ }),
+ );
+ });
+
+ it('should emit error notification on failed remove save for later', async () => {
+ const client = await getTestClientWithUser(alice);
+ const message = generateMessage();
+ client.reminders.hydrateState([
+ generateMessage({
+ ...message,
+ reminder: generateReminderResponse({
+ data: { message_id: message.id },
+ }),
+ }),
+ ]);
+ vi.spyOn(client.reminders, 'deleteReminder').mockRejectedValueOnce(
+ new Error('Remove failed'),
+ );
+ const addNotificationSpy = vi.spyOn(client.notifications, 'add');
+
+ await renderMessageActions({
+ channelConfig: { user_message_reminders: true },
+ chatClient: client,
+ customMessageContext: {
+ getMessageActions: getMessageActionsWithReminders,
+ message,
+ },
+ });
+ await toggleOpenMessageActions();
+
+ await act(async () => {
+ await fireEvent.click(screen.getByText(REMOVE_ACTION_TEXT));
+ });
+
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Remove failed',
+ options: expect.objectContaining({
+ severity: 'error',
+ type: 'api:message:saveForLater:delete:failed',
+ }),
+ }),
);
});
});
diff --git a/src/components/MessageComposer/hooks/useSubmitHandler.ts b/src/components/MessageComposer/hooks/useSubmitHandler.ts
index bd4de8da78..d6cb4a87c9 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/MessageList/ConnectionStatus.tsx b/src/components/MessageList/ConnectionStatus.tsx
deleted file mode 100644
index 4418ae6584..0000000000
--- a/src/components/MessageList/ConnectionStatus.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React, { useEffect, useState } from 'react';
-
-import type { Event } from 'stream-chat';
-
-import { CustomNotification } from './CustomNotification';
-import { useChatContext, useTranslationContext } from '../../context';
-
-const UnMemoizedConnectionStatus = () => {
- const { client } = useChatContext('ConnectionStatus');
- const { t } = useTranslationContext('ConnectionStatus');
-
- const [online, setOnline] = useState(true);
-
- useEffect(() => {
- const connectionChanged = ({ online: onlineStatus = false }: Event) => {
- if (online !== onlineStatus) {
- setOnline(onlineStatus);
- }
- };
-
- client.on('connection.changed', connectionChanged);
- return () => client.off('connection.changed', connectionChanged);
- }, [client, online]);
-
- return (
-
- {t('Connection failure, reconnecting now...')}
-
- );
-};
-
-export const ConnectionStatus = React.memo(UnMemoizedConnectionStatus);
diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx
index fbb0119ee3..402fffdf48 100644
--- a/src/components/MessageList/MessageList.tsx
+++ b/src/components/MessageList/MessageList.tsx
@@ -207,15 +207,6 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
closeReactionSelectorOnClick: props.closeReactionSelectorOnClick,
disableQuotedMessages: props.disableQuotedMessages,
formatDate: props.formatDate,
- getDeleteMessageErrorNotification: props.getDeleteMessageErrorNotification,
- getFlagMessageErrorNotification: props.getFlagMessageErrorNotification,
- getFlagMessageSuccessNotification: props.getFlagMessageSuccessNotification,
- getMarkMessageUnreadErrorNotification: props.getMarkMessageUnreadErrorNotification,
- getMarkMessageUnreadSuccessNotification:
- props.getMarkMessageUnreadSuccessNotification,
- getMuteUserErrorNotification: props.getMuteUserErrorNotification,
- getMuteUserSuccessNotification: props.getMuteUserSuccessNotification,
- getPinMessageErrorNotification: props.getPinMessageErrorNotification,
Message: props.Message,
messageActions,
messageListRect: wrapperRect,
@@ -474,14 +465,6 @@ type PropsDrilledToMessage =
| 'closeReactionSelectorOnClick'
| 'disableQuotedMessages'
| 'formatDate'
- | 'getDeleteMessageErrorNotification'
- | 'getFlagMessageErrorNotification'
- | 'getFlagMessageSuccessNotification'
- | 'getMarkMessageUnreadErrorNotification'
- | 'getMarkMessageUnreadSuccessNotification'
- | 'getMuteUserErrorNotification'
- | 'getMuteUserSuccessNotification'
- | 'getPinMessageErrorNotification'
| 'Message'
| 'messageActions'
| 'onlySenderCanEdit'
diff --git a/src/components/MessageList/__tests__/ConnectionStatus.test.tsx b/src/components/MessageList/__tests__/ConnectionStatus.test.tsx
deleted file mode 100644
index dec8075c9f..0000000000
--- a/src/components/MessageList/__tests__/ConnectionStatus.test.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
-import type { StreamChat } from 'stream-chat';
-import { dispatchConnectionChangedEvent, getTestClient } from 'mock-builders';
-
-import { ConnectionStatus } from '../ConnectionStatus';
-import { Chat } from '../../Chat';
-
-const customNotificationId = 'custom-notification';
-describe(' component', () => {
- let chatClient: StreamChat;
- beforeEach(() => {
- chatClient = getTestClient();
- });
-
- afterEach(cleanup);
-
- it('should render nothing by default', async () => {
- let container: HTMLElement;
- await act(() => {
- const result = render(
-
-
- ,
- );
- container = result.container;
- });
-
- expect(container.firstChild).toMatchInlineSnapshot(`null`);
- });
-
- it('should render and hide the status based on online state', async () => {
- act(() => {
- render(
-
-
- ,
- );
- });
-
- // default to online
- expect(screen.queryByTestId(customNotificationId)).not.toBeInTheDocument();
-
- // offline
- act(() => dispatchConnectionChangedEvent(chatClient, false));
- await waitFor(() => {
- expect(screen.queryByTestId(customNotificationId)).toBeInTheDocument();
- });
-
- // online again
- act(() => dispatchConnectionChangedEvent(chatClient, true));
- await waitFor(() => {
- expect(screen.queryByTestId(customNotificationId)).not.toBeInTheDocument();
- });
- });
-
- it('should render a proper message when client is offline', async () => {
- await act(() => {
- render(
-
-
- ,
- );
- });
-
- // offline
- act(() => {
- dispatchConnectionChangedEvent(chatClient, false);
- });
- await waitFor(() => {
- expect(screen.queryByTestId(customNotificationId)).toBeInTheDocument();
- expect(screen.queryByTestId(customNotificationId)).toHaveTextContent(
- 'Connection failure, reconnecting now...',
- );
- });
- });
-});
diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx
index 3f550ab9bb..bad42b0c1e 100644
--- a/src/components/MessageList/__tests__/MessageList.test.tsx
+++ b/src/components/MessageList/__tests__/MessageList.test.tsx
@@ -1394,37 +1394,37 @@ describe('MessageList', () => {
});
describe('props forwarded to Message', () => {
- it.each([
- ['getMarkMessageUnreadErrorNotification'],
- ['getMarkMessageUnreadSuccessNotification'],
- ])('calls %s', async (funcName) => {
- const markUnreadSpy = vi.spyOn(channel, 'markUnread');
- if (funcName === 'getMarkMessageUnreadErrorNotification')
- markUnreadSpy.mockRejectedValueOnce(undefined!);
-
- const message = generateMessage();
- const notificationFunc = vi.fn();
- const Message = () => {
- const { handleMarkUnread } = useMessageContext();
- useEffect(() => {
- const event = fromPartial({
- preventDefault: () => null,
- });
- handleMarkUnread(event);
- }, [handleMarkUnread]);
- return null;
- };
+ it.each([[true], [false]])(
+ 'invokes handleMarkUnread from Message context (shouldFail: %s)',
+ async (shouldFail) => {
+ const markUnreadSpy = vi.spyOn(channel, 'markUnread');
+ if (shouldFail) markUnreadSpy.mockRejectedValueOnce(undefined!);
+
+ const message = generateMessage();
+ const Message = () => {
+ const { handleMarkUnread } = useMessageContext();
+ useEffect(() => {
+ const event = fromPartial({
+ preventDefault: () => null,
+ });
+ void handleMarkUnread(event).catch(() => undefined);
+ }, [handleMarkUnread]);
+ return null;
+ };
- await act(() => {
- renderComponent({
- channelProps: { channel },
- chatClient,
- msgListProps: { [funcName]: notificationFunc, Message, messages: [message] },
+ await act(() => {
+ renderComponent({
+ channelProps: { channel },
+ chatClient,
+ msgListProps: { Message, messages: [message] },
+ });
});
- });
- expect(notificationFunc).toHaveBeenCalledWith(expect.objectContaining(message));
- });
+ expect(markUnreadSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ message_id: message.id }),
+ );
+ },
+ );
});
describe('list wrapper and list item overrides', () => {
diff --git a/src/components/MessageList/index.ts b/src/components/MessageList/index.ts
index ae3c7be95f..373ad8cc85 100644
--- a/src/components/MessageList/index.ts
+++ b/src/components/MessageList/index.ts
@@ -1,4 +1,3 @@
-export * from './ConnectionStatus'; // TODO: export this under its own folder
export * from './GiphyPreviewMessage';
export * from './MessageList';
export * from './NewMessageNotification';
diff --git a/src/components/Notifications/Notification.tsx b/src/components/Notifications/Notification.tsx
index e37ba670e9..4cd127287d 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 326ebe54ab..8ea69fdd67 100644
--- a/src/components/Notifications/NotificationList.tsx
+++ b/src/components/Notifications/NotificationList.tsx
@@ -2,9 +2,10 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import type { Notification } from 'stream-chat';
-import { useChatContext } from '../../context';
+import { hasSystemNotificationTag, useNotificationApi } from './hooks/useNotificationApi';
import { useNotifications } from './hooks/useNotifications';
-import { Notification as NotificationComponent } from './Notification';
+import { Notification as DefaultNotification } from './Notification';
+import { useComponentContext } from '../../context';
import type { NotificationTargetPanel } from './notificationTarget';
@@ -71,7 +72,9 @@ export const NotificationList = ({
panel,
verticalAlignment = 'bottom',
}: NotificationListProps) => {
- const { client } = useChatContext();
+ const { Notification: NotificationComponent = DefaultNotification } =
+ useComponentContext();
+ const { removeNotification, startNotificationTimeout } = useNotificationApi();
const exitTimeoutRef = useRef(null);
const latestNotificationRef = useRef(null);
const listRef = useRef(null);
@@ -86,15 +89,26 @@ export const NotificationList = ({
null,
);
const [transitionState, setTransitionState] = useState<'enter' | 'exit'>('enter');
- const notifications = useNotifications({ fallbackPanel, filter, panel });
+ const combinedFilter = useCallback(
+ (notification: Notification) => {
+ if (hasSystemNotificationTag(notification)) return false;
+ return filter ? filter(notification) : true;
+ },
+ [filter],
+ );
+ const notifications = useNotifications({
+ fallbackPanel,
+ filter: combinedFilter,
+ panel,
+ });
const nextNotification = notifications[0] ?? null;
const dismiss = useCallback(
(id: string) => {
startedTimeoutIdsRef.current?.delete(id);
- client.notifications.remove(id);
+ removeNotification(id);
},
- [client],
+ [removeNotification],
);
useEffect(() => {
@@ -155,7 +169,7 @@ export const NotificationList = ({
return;
startedTimeoutIdsRef.current.add(notification.id);
- client.notifications.startTimeout(notification.id);
+ startNotificationTimeout(notification.id);
};
if (typeof IntersectionObserver === 'undefined') {
@@ -182,7 +196,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 7b4fb8c547..1478e42429 100644
--- a/src/components/Notifications/__tests__/NotificationList.test.tsx
+++ b/src/components/Notifications/__tests__/NotificationList.test.tsx
@@ -1,22 +1,21 @@
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 { ComponentProvider } from '../../../context/ComponentContext';
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 +49,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 +112,12 @@ 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(),
+ addSystemNotification: vi.fn(),
+ removeNotification: remove,
+ startNotificationTimeout: startTimeout,
+ });
remove.mockImplementation((id: string) => {
currentNotifications = currentNotifications.filter(
(notification) => notification.id !== id,
@@ -130,10 +129,9 @@ describe('NotificationList', () => {
afterEach(() => {
vi.useRealTimers();
- clearTimeout.mockReset();
remove.mockReset();
startTimeout.mockReset();
- mockedUseChatContext.mockReset();
+ mockedUseNotificationApi.mockReset();
mockedUseNotifications.mockReset();
delete window['IntersectionObserver'];
});
@@ -216,4 +214,26 @@ describe('NotificationList', () => {
'left',
);
});
+
+ it('uses custom Notification component from ComponentContext', () => {
+ const CustomNotification = React.forwardRef<
+ HTMLDivElement,
+ {
+ notification: { id: string; message: string };
+ }
+ >(({ notification }, ref) => (
+
+ {notification.message}
+
+ ));
+ CustomNotification.displayName = 'CustomNotification';
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('custom-notification-n-1')).toBeInTheDocument();
+ });
});
diff --git a/src/components/Notifications/__tests__/notificationOrigin.test.ts b/src/components/Notifications/__tests__/notificationOrigin.test.ts
index eb768d7005..6fbdd13241 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 0000000000..90e5b95ff3
--- /dev/null
+++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts
@@ -0,0 +1,332 @@
+import { renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+
+import { useChatContext } from '../../../../context';
+import { useNotificationTarget } from '../useNotificationTarget';
+import type { Notification } from 'stream-chat';
+
+import {
+ hasSystemNotificationTag,
+ SYSTEM_NOTIFICATION_TAG,
+ useNotificationApi,
+} from '../useNotificationApi';
+
+vi.mock('../../../../context', () => ({
+ useChatContext: vi.fn(),
+}));
+
+vi.mock('../useNotificationTarget', () => ({
+ useNotificationTarget: vi.fn(),
+}));
+
+const add = vi.fn();
+const remove = vi.fn();
+const startTimeout = vi.fn();
+
+const mockedUseChatContext = vi.mocked(useChatContext);
+const mockedUseNotificationTarget = vi.mocked(useNotificationTarget);
+
+describe('useNotificationApi', () => {
+ beforeEach(() => {
+ mockedUseNotificationTarget.mockReturnValue('channel');
+ mockedUseChatContext.mockReturnValue(
+ fromPartial({
+ client: {
+ notifications: {
+ add,
+ remove,
+ startTimeout,
+ },
+ },
+ }),
+ );
+ });
+
+ afterEach(() => {
+ 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');
+
+ 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: 'api:channel:mute:failed',
+ });
+
+ expect(add).toHaveBeenCalledWith({
+ message: 'Failed to update channel mute status',
+ options: {
+ severity: 'error',
+ tags: ['target:thread', 'target:channel-list', 'custom-tag'],
+ type: 'api:channel: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' },
+ });
+ });
+
+ it('addSystemNotification applies system tag and skips panel target tags', () => {
+ mockedUseNotificationTarget.mockReturnValue('thread');
+
+ const { result } = renderHook(() => useNotificationApi());
+
+ result.current.addSystemNotification({
+ duration: 0,
+ emitter: 'Chat',
+ message: 'Waiting for network…',
+ severity: 'loading',
+ type: 'system:network:connection:lost',
+ });
+
+ expect(add).toHaveBeenCalledWith({
+ message: 'Waiting for network…',
+ options: {
+ duration: 0,
+ severity: 'loading',
+ tags: ['system'],
+ type: 'system:network:connection:lost',
+ },
+ origin: { emitter: 'Chat' },
+ });
+ });
+
+ it('addSystemNotification merges extra tags with system tag', () => {
+ const { result } = renderHook(() => useNotificationApi());
+
+ result.current.addSystemNotification({
+ emitter: 'App',
+ message: 'Maintenance',
+ severity: 'warning',
+ tags: ['custom'],
+ type: 'app:maintenance:upcoming',
+ });
+
+ expect(add).toHaveBeenCalledWith({
+ message: 'Maintenance',
+ options: {
+ severity: 'warning',
+ tags: ['system', 'custom'],
+ type: 'app:maintenance:upcoming',
+ },
+ origin: { emitter: 'App' },
+ });
+ });
+});
+
+describe('system notification tag helpers', () => {
+ const base: Notification = {
+ createdAt: 1,
+ id: 'n1',
+ message: 'x',
+ origin: { emitter: 't' },
+ };
+
+ it('SYSTEM_NOTIFICATION_TAG is system', () => {
+ expect(SYSTEM_NOTIFICATION_TAG).toBe('system');
+ });
+
+ it('hasSystemNotificationTag returns true when system tag is present', () => {
+ expect(hasSystemNotificationTag({ ...base, tags: ['system'] })).toBe(true);
+ });
+
+ it('hasSystemNotificationTag returns false when tag is missing or different', () => {
+ expect(hasSystemNotificationTag(base)).toBe(false);
+ expect(hasSystemNotificationTag({ ...base, tags: ['channel'] })).toBe(false);
+ });
+});
diff --git a/src/components/Notifications/hooks/__tests__/useSystemNotifications.test.ts b/src/components/Notifications/hooks/__tests__/useSystemNotifications.test.ts
new file mode 100644
index 0000000000..0542445e1d
--- /dev/null
+++ b/src/components/Notifications/hooks/__tests__/useSystemNotifications.test.ts
@@ -0,0 +1,74 @@
+import { renderHook } from '@testing-library/react';
+import { fromPartial } from '@total-typescript/shoehorn';
+
+import { useChatContext } from '../../../../context';
+import { useStateStore } from '../../../../store';
+import { useSystemNotifications } from '../useSystemNotifications';
+
+import type { Notification } from 'stream-chat';
+
+vi.mock('../../../../context', () => ({
+ useChatContext: vi.fn(),
+}));
+
+vi.mock('../../../../store', () => ({
+ useStateStore: vi.fn(),
+}));
+
+const mockedUseChatContext = vi.mocked(useChatContext);
+const mockedUseStateStore = vi.mocked(useStateStore);
+
+const baseNotification = (overrides: Partial): Notification =>
+ fromPartial({
+ createdAt: 1,
+ id: 'n1',
+ message: 'msg',
+ origin: { emitter: 't' },
+ ...overrides,
+ });
+
+describe('useSystemNotifications', () => {
+ beforeEach(() => {
+ mockedUseChatContext.mockReturnValue(
+ fromPartial({
+ client: {
+ notifications: { store: {} },
+ },
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('returns only notifications with the system tag', () => {
+ const sys = baseNotification({ id: 'sys', tags: ['system'] });
+ const other = baseNotification({ id: 'other', tags: ['target:channel'] });
+
+ mockedUseStateStore.mockImplementation((_store, selector) =>
+ selector(fromPartial({ notifications: [sys, other] })),
+ );
+
+ const { result } = renderHook(() => useSystemNotifications());
+
+ expect(result.current).toEqual([sys]);
+ });
+
+ it('applies optional filter after system tag', () => {
+ const a = baseNotification({ id: 'a', severity: 'error', tags: ['system'] });
+ const b = baseNotification({ id: 'b', severity: 'info', tags: ['system'] });
+
+ mockedUseStateStore.mockImplementation((_store, selector) =>
+ selector(fromPartial({ notifications: [a, b] })),
+ );
+
+ const { result } = renderHook(() =>
+ useSystemNotifications({
+ filter: (n) => n.severity === 'error',
+ }),
+ );
+
+ expect(result.current).toEqual([a]);
+ });
+});
diff --git a/src/components/Notifications/hooks/index.ts b/src/components/Notifications/hooks/index.ts
index cdd0149777..6598ca5c69 100644
--- a/src/components/Notifications/hooks/index.ts
+++ b/src/components/Notifications/hooks/index.ts
@@ -1,2 +1,4 @@
export * from './useNotifications';
+export * from './useSystemNotifications';
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 0000000000..027616a741
--- /dev/null
+++ b/src/components/Notifications/hooks/useNotificationApi.ts
@@ -0,0 +1,208 @@
+import { useCallback } from 'react';
+
+import type { Notification, NotificationAction, NotificationSeverity } from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import {
+ addNotificationTargetTag,
+ getNotificationTargetTag,
+ type NotificationTargetPanel,
+} from '../notificationTarget';
+import { useNotificationTarget } from './useNotificationTarget';
+
+/** Tag used for full-width system banners (e.g. connection status). Excluded from `NotificationList` by default. */
+export const SYSTEM_NOTIFICATION_TAG = 'system' as const;
+
+export const hasSystemNotificationTag = (notification: Notification) =>
+ notification.tags?.includes(SYSTEM_NOTIFICATION_TAG) ?? false;
+
+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;
+};
+
+/**
+ * Same shape as {@link AddNotificationParams} except `targetPanels` is omitted — system
+ * banners are global and do not receive `target:*` panel tags (they are filtered by the
+ * `system` tag for `NotificationList` vs banner UIs).
+ */
+export type AddSystemNotificationParams = Omit;
+
+export type AddNotification = (params: AddNotificationParams) => void;
+/** Returns the notification id (for removal / timeouts). */
+export type AddSystemNotification = (params: AddSystemNotificationParams) => string;
+export type RemoveNotification = (id: string) => void;
+export type StartNotificationTimeout = (id: string) => void;
+
+export type NotificationApi = {
+ addNotification: AddNotification;
+ addSystemNotification: AddSystemNotification;
+ removeNotification: RemoveNotification;
+ startNotificationTimeout: StartNotificationTimeout;
+};
+
+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 = (): NotificationApi => {
+ const { client } = useChatContext();
+ const inferredPanel = useNotificationTarget();
+
+ const addNotification: 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],
+ );
+
+ const addSystemNotification: AddSystemNotification = useCallback(
+ ({
+ actions,
+ context,
+ duration,
+ emitter,
+ error,
+ incident,
+ message,
+ severity,
+ tags,
+ type,
+ }: AddSystemNotificationParams) => {
+ const notificationTags = Array.from(
+ new Set([SYSTEM_NOTIFICATION_TAG, ...(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 } : {}),
+ };
+
+ return client.notifications.add({
+ message,
+ options,
+ origin,
+ });
+ },
+ [client],
+ );
+
+ const removeNotification: RemoveNotification = useCallback(
+ (id) => {
+ client.notifications.remove(id);
+ },
+ [client],
+ );
+
+ const startNotificationTimeout: StartNotificationTimeout = useCallback(
+ (id) => {
+ client.notifications.startTimeout(id);
+ },
+ [client],
+ );
+
+ return {
+ addNotification,
+ addSystemNotification,
+ removeNotification,
+ startNotificationTimeout,
+ };
+};
diff --git a/src/components/Notifications/hooks/useSystemNotifications.ts b/src/components/Notifications/hooks/useSystemNotifications.ts
new file mode 100644
index 0000000000..6f95ee814e
--- /dev/null
+++ b/src/components/Notifications/hooks/useSystemNotifications.ts
@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+
+import type { Notification, NotificationManagerState } from 'stream-chat';
+
+import { useChatContext } from '../../../context';
+import { useStateStore } from '../../../store';
+
+import { hasSystemNotificationTag } from './useNotificationApi';
+
+export type UseSystemNotificationsFilter = (notification: Notification) => boolean;
+
+export type UseSystemNotificationsOptions = {
+ /**
+ * Applied after the built-in filter that keeps only notifications tagged for the system banner.
+ */
+ filter?: UseSystemNotificationsFilter;
+};
+
+/**
+ * Subscribes to `client.notifications` and returns only **system** banner notifications
+ * (same subset `NotificationList` excludes from toasts). Optional `filter` narrows further.
+ */
+export const useSystemNotifications = (
+ options?: UseSystemNotificationsOptions,
+): Notification[] => {
+ const { client } = useChatContext();
+ const selector = useCallback(
+ (state: NotificationManagerState) => {
+ const withSystemTag = state.notifications.filter(hasSystemNotificationTag);
+ const notifications = options?.filter
+ ? withSystemTag.filter(options.filter)
+ : withSystemTag;
+
+ return { notifications };
+ },
+ [options?.filter],
+ );
+
+ const { notifications } = useStateStore(client.notifications.store, selector);
+
+ return notifications;
+};
diff --git a/src/components/Notifications/notificationTarget.ts b/src/components/Notifications/notificationTarget.ts
index ad20b9599a..4bb49f6236 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;
};
diff --git a/src/components/Poll/PollActions/EndPollAlert.tsx b/src/components/Poll/PollActions/EndPollAlert.tsx
index 243dc39ee2..c8bc73b48f 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',
});
}
}}
diff --git a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
index 13e0e702b7..68e64e2985 100644
--- a/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
+++ b/src/components/Reactions/__tests__/MessageReactionsDetail.test.tsx
@@ -238,4 +238,35 @@ describe('MessageReactionsDetail', () => {
).toStrictEqual(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
+
+ it('emits typed notification when fetching reactions fails', async () => {
+ const reactionGroups = {
+ haha: { count: 1 },
+ };
+ const reactions = generateReactionsFromReactionGroups(reactionGroups);
+ const fetchError = new Error('reactions failed');
+ const fetchReactions = vi.fn(() => Promise.reject(fetchError));
+ const addNotificationSpy = vi.spyOn(chatClient.notifications, 'add');
+
+ renderComponent({
+ handleFetchReactions: fetchReactions,
+ reaction_groups: reactionGroups,
+ reactions,
+ selectedReactionType: 'haha',
+ });
+
+ await waitFor(() => {
+ expect(addNotificationSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: 'Error fetching reactions',
+ options: expect.objectContaining({
+ originalError: fetchError,
+ severity: 'error',
+ type: 'api:message:reactions:fetch:failed',
+ }),
+ origin: expect.objectContaining({ emitter: 'Reactions' }),
+ }),
+ );
+ });
+ });
});
diff --git a/src/components/Reactions/hooks/useFetchReactions.ts b/src/components/Reactions/hooks/useFetchReactions.ts
index f5a71d2c52..a619891401 100644
--- a/src/components/Reactions/hooks/useFetchReactions.ts
+++ b/src/components/Reactions/hooks/useFetchReactions.ts
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import type { ReactionResponse, ReactionSort } from 'stream-chat';
import type { MessageContextValue } from '../../../context';
-import { useMessageContext } from '../../../context';
+import { useMessageContext, useTranslationContext } from '../../../context';
+import { useNotificationApi } from '../../Notifications';
import type { ReactionType } from '../types';
@@ -13,8 +14,10 @@ export interface FetchReactionsOptions {
}
export function useFetchReactions(options: FetchReactionsOptions) {
+ const { addNotification } = useNotificationApi();
const { handleFetchReactions: contextHandleFetchReactions } =
useMessageContext('useFetchReactions');
+ const { t } = useTranslationContext('useFetchReactions');
const [reactions, setReactions] = useState([]);
const {
handleFetchReactions: propHandleFetchReactions,
@@ -44,6 +47,13 @@ export function useFetchReactions(options: FetchReactionsOptions) {
} catch (e) {
if (!cancel) {
setReactions([]);
+ addNotification({
+ emitter: 'Reactions',
+ error: e instanceof Error ? e : undefined,
+ message: t('Error fetching reactions'),
+ severity: 'error',
+ type: 'api:message:reactions:fetch:failed',
+ });
}
} finally {
if (!cancel) {
@@ -55,7 +65,15 @@ export function useFetchReactions(options: FetchReactionsOptions) {
return () => {
cancel = true;
};
- }, [handleFetchReactions, reactionType, shouldFetch, sort, refetchNonce]);
+ }, [
+ addNotification,
+ handleFetchReactions,
+ reactionType,
+ refetchNonce,
+ shouldFetch,
+ sort,
+ t,
+ ]);
const refetch = useCallback(() => {
setRefetchNonce(Math.random());
diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx
index eae76a1994..8effb1042f 100644
--- a/src/context/ComponentContext.tsx
+++ b/src/context/ComponentContext.tsx
@@ -37,6 +37,7 @@ import {
type ModalProps,
type NewMessageNotificationProps,
type NotificationListProps,
+ type NotificationProps,
type PinIndicatorProps,
type PollCreationDialogProps,
type PollOptionSelectorProps,
@@ -168,6 +169,10 @@ export type ComponentContextValue = {
/** Custom UI component for a message bubble of a deleted message, defaults to and accepts same props as: [MessageDeletedBubble](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageDeletedBubble.tsx) */
MessageDeletedBubble?: React.ComponentType;
MessageListMainPanel?: React.ComponentType;
+ /** Custom UI component to render a single notification item in NotificationList, defaults to and accepts same props as: [Notification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Notifications/Notification.tsx) */
+ Notification?: React.ForwardRefExoticComponent<
+ NotificationProps & React.RefAttributes
+ >;
/** Custom UI component to display notifications rendered by `NotificationList`, defaults to and accepts same props as: [NotificationList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Notifications/NotificationList.tsx) */
NotificationList?: React.ComponentType;
/** Custom UI component to display a notification when scrolled up the list and new messages arrive, defaults to and accepts same props as [NewMessageNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/NewMessageNotification.tsx) */
diff --git a/src/i18n/de.json b/src/i18n/de.json
index c9ac9f639f..05f9b42255 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -115,7 +115,13 @@
"Block User": "Benutzer blockieren",
"Cancel": "Abbrechen",
"Cannot seek in the recording": "In der Aufnahme kann nicht gesucht werden",
+ "Channel archived": "Kanal archiviert",
"Channel Missing": "Kanal fehlt",
+ "Channel muted": "Kanal stummgeschaltet",
+ "Channel pinned": "Kanal angeheftet",
+ "Channel unarchived": "Kanal dearchiviert",
+ "Channel unmuted": "Stummschaltung des Kanals aufgehoben",
+ "Channel unpinned": "Kanal nicht mehr angeheftet",
"Channels": "Kanäle",
"Chats": "Chats",
"Choose between 2 to 10 options": "Wähle zwischen 2 und 10 Optionen",
@@ -256,6 +262,7 @@
"language/zh": "Chinesisch (Vereinfacht)",
"language/zh-TW": "Chinesisch (Traditionell)",
"Leave Channel": "Kanal verlassen",
+ "Left channel": "Kanal verlassen",
"Let others add options": "Andere Optionen hinzufügen lassen",
"Limit votes per person": "Stimmen pro Person begrenzen",
"Link": "Link",
@@ -276,7 +283,9 @@
"Message deleted": "Nachricht gelöscht",
"Message failed to send": "Nachricht konnte nicht gesendet werden",
"Message has been successfully flagged": "Nachricht wurde erfolgreich gemeldet",
+ "Message marked as unread": "Nachricht als ungelesen markiert",
"Message pinned": "Nachricht angeheftet",
+ "Message unpinned": "Nachricht nicht mehr angeheftet",
"Message was blocked by moderation policies": "Nachricht wurde durch moderationsrichtlinien blockiert",
"Messages have been marked unread.": "Nachrichten wurden als ungelesen markiert.",
"Missing permissions to upload the attachment": "Fehlende Berechtigungen zum Hochladen des Anhangs",
@@ -430,6 +439,8 @@
"Upload error": "Upload-Fehler",
"Upload failed": "Upload fehlgeschlagen",
"Upload type: \"{{ type }}\" is not allowed": "Upload-Typ: \"{{ type }}\" ist nicht erlaubt",
+ "User blocked": "Benutzer blockiert",
+ "User unblocked": "Blockierung des Benutzers aufgehoben",
"User uploaded content": "Vom Benutzer hochgeladener Inhalt",
"Video": "Video",
"videoCount_one": "Video",
@@ -449,6 +460,7 @@
"Vote ended": "Abstimmung beendet",
"Votes": "Stimmen",
"Wait until all attachments have uploaded": "Bitte warten, bis alle Anhänge hochgeladen wurden",
+ "Waiting for network…": "Warte auf Netzwerk…",
"You": "Du",
"You've reached the maximum number of files": "Die maximale Anzahl an Dateien ist erreicht"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index e0a1f27e8f..00b2660f06 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -115,7 +115,13 @@
"Block User": "Block User",
"Cancel": "Cancel",
"Cannot seek in the recording": "Cannot seek in the recording",
+ "Channel archived": "Channel archived",
"Channel Missing": "Channel Missing",
+ "Channel muted": "Channel muted",
+ "Channel pinned": "Channel pinned",
+ "Channel unarchived": "Channel unarchived",
+ "Channel unmuted": "Channel unmuted",
+ "Channel unpinned": "Channel unpinned",
"Channels": "Channels",
"Chats": "Chats",
"Choose between 2 to 10 options": "Choose Between 2 to 10 Options",
@@ -256,6 +262,7 @@
"language/zh": "Chinese (Simplified)",
"language/zh-TW": "Chinese (Traditional)",
"Leave Channel": "Leave Channel",
+ "Left channel": "Left channel",
"Let others add options": "Let Others Add Options",
"Limit votes per person": "Limit Votes per Person",
"Link": "Link",
@@ -276,7 +283,9 @@
"Message deleted": "Message deleted",
"Message failed to send": "Message failed to send",
"Message has been successfully flagged": "Message has been successfully flagged",
+ "Message marked as unread": "Message marked as unread",
"Message pinned": "Message pinned",
+ "Message unpinned": "Message unpinned",
"Message was blocked by moderation policies": "Message was blocked by moderation policies",
"Messages have been marked unread.": "Messages have been marked unread.",
"Missing permissions to upload the attachment": "Missing permissions to upload the attachment",
@@ -430,6 +439,8 @@
"Upload error": "Upload error",
"Upload failed": "Upload failed",
"Upload type: \"{{ type }}\" is not allowed": "Upload type: \"{{ type }}\" is not allowed",
+ "User blocked": "User blocked",
+ "User unblocked": "User unblocked",
"User uploaded content": "User uploaded content",
"Video": "Video",
"videoCount_one": "Video",
@@ -449,6 +460,7 @@
"Vote ended": "Vote ended",
"Votes": "Votes",
"Wait until all attachments have uploaded": "Wait until all attachments have uploaded",
+ "Waiting for network…": "Waiting for network…",
"You": "You",
"You've reached the maximum number of files": "You've reached the maximum number of files"
}
diff --git a/src/i18n/es.json b/src/i18n/es.json
index e5e5d4e380..18c6a1deb8 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -123,7 +123,13 @@
"Block User": "Bloquear usuario",
"Cancel": "Cancelar",
"Cannot seek in the recording": "No se puede buscar en la grabación",
+ "Channel archived": "Canal archivado",
"Channel Missing": "Falta canal",
+ "Channel muted": "Canal silenciado",
+ "Channel pinned": "Canal fijado",
+ "Channel unarchived": "Canal desarchivado",
+ "Channel unmuted": "Silencio del canal desactivado",
+ "Channel unpinned": "Canal desanclado",
"Channels": "Canales",
"Chats": "Chats",
"Choose between 2 to 10 options": "Elige entre 2 y 10 opciones",
@@ -266,6 +272,7 @@
"language/zh": "Chino (simplificado)",
"language/zh-TW": "Chino (tradicional)",
"Leave Channel": "Abandonar canal",
+ "Left channel": "Canal abandonado",
"Let others add options": "Permitir que otros añadan opciones",
"Limit votes per person": "Limitar votos por persona",
"Link": "Enlace",
@@ -287,7 +294,9 @@
"Message deleted": "Mensaje eliminado",
"Message failed to send": "No se pudo enviar el mensaje",
"Message has been successfully flagged": "El mensaje se marcó correctamente",
+ "Message marked as unread": "Mensaje marcado como no leído",
"Message pinned": "Mensaje fijado",
+ "Message unpinned": "Mensaje desanclado",
"Message was blocked by moderation policies": "El mensaje fue bloqueado por las políticas de moderación",
"Messages have been marked unread.": "Los mensajes han sido marcados como no leídos.",
"Missing permissions to upload the attachment": "Faltan permisos para subir el archivo adjunto",
@@ -446,6 +455,8 @@
"Upload error": "Error de carga",
"Upload failed": "Carga fallida",
"Upload type: \"{{ type }}\" is not allowed": "Tipo de carga: \"{{ type }}\" no está permitido",
+ "User blocked": "Usuario bloqueado",
+ "User unblocked": "Usuario desbloqueado",
"User uploaded content": "Contenido subido por el usuario",
"Video": "Vídeo",
"videoCount_one": "Video",
@@ -468,6 +479,7 @@
"Vote ended": "Votación finalizada",
"Votes": "Votos",
"Wait until all attachments have uploaded": "Espere hasta que se hayan cargado todos los archivos adjuntos",
+ "Waiting for network…": "Esperando red…",
"You": "Tú",
"You've reached the maximum number of files": "Has alcanzado el número máximo de archivos"
}
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 3c54f13c12..1aefa165a7 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -123,7 +123,13 @@
"Block User": "Bloquer l'utilisateur",
"Cancel": "Annuler",
"Cannot seek in the recording": "Impossible de rechercher dans l'enregistrement",
+ "Channel archived": "Canal archivé",
"Channel Missing": "Canal Manquant",
+ "Channel muted": "Canal mis en sourdine",
+ "Channel pinned": "Canal épinglé",
+ "Channel unarchived": "Canal désarchivé",
+ "Channel unmuted": "Sourdine du canal désactivée",
+ "Channel unpinned": "Canal désépinglé",
"Channels": "Canaux",
"Chats": "Discussions",
"Choose between 2 to 10 options": "Choisir entre 2 et 10 options",
@@ -266,6 +272,7 @@
"language/zh": "Chinois (simplifié)",
"language/zh-TW": "Chinois (traditionnel)",
"Leave Channel": "Quitter le canal",
+ "Left channel": "Canal quitté",
"Let others add options": "Permettre à d'autres d'ajouter des options",
"Limit votes per person": "Limiter les votes par personne",
"Link": "Lien",
@@ -287,7 +294,9 @@
"Message deleted": "Message supprimé",
"Message failed to send": "Échec de l'envoi du message",
"Message has been successfully flagged": "Le message a été signalé avec succès",
+ "Message marked as unread": "Message marqué comme non lu",
"Message pinned": "Message épinglé",
+ "Message unpinned": "Message désépinglé",
"Message was blocked by moderation policies": "Le message a été bloqué par les politiques de modération",
"Messages have been marked unread.": "Les messages ont été marqués comme non lus.",
"Missing permissions to upload the attachment": "Autorisations manquantes pour télécharger la pièce jointe",
@@ -446,6 +455,8 @@
"Upload error": "Erreur de téléversement",
"Upload failed": "Échec du téléversement",
"Upload type: \"{{ type }}\" is not allowed": "Le type de fichier : \"{{ type }}\" n'est pas autorisé",
+ "User blocked": "Utilisateur bloqué",
+ "User unblocked": "Utilisateur débloqué",
"User uploaded content": "Contenu téléchargé par l'utilisateur",
"Video": "Vidéo",
"videoCount_one": "Vidéo",
@@ -468,6 +479,7 @@
"Vote ended": "Vote terminé",
"Votes": "Votes",
"Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient téléchargées",
+ "Waiting for network…": "En attente du réseau…",
"You": "Vous",
"You've reached the maximum number of files": "Vous avez atteint le nombre maximal de fichiers"
}
diff --git a/src/i18n/hi.json b/src/i18n/hi.json
index aec01fb3d8..dfedcdd9b0 100644
--- a/src/i18n/hi.json
+++ b/src/i18n/hi.json
@@ -115,7 +115,13 @@
"Block User": "उपयोगकर्ता को ब्लॉक करें",
"Cancel": "रद्द करें",
"Cannot seek in the recording": "रेकॉर्डिंग में खोज नहीं की जा सकती",
+ "Channel archived": "चैनल संग्रहीत किया गया",
"Channel Missing": "चैनल उपलब्ध नहीं है",
+ "Channel muted": "चैनल म्यूट किया गया",
+ "Channel pinned": "चैनल पिन किया गया",
+ "Channel unarchived": "चैनल असंग्रहीत किया गया",
+ "Channel unmuted": "चैनल अनम्यूट किया गया",
+ "Channel unpinned": "चैनल अनपिन किया गया",
"Channels": "चैनल",
"Chats": "चैट",
"Choose between 2 to 10 options": "2 से 10 विकल्प चुनें",
@@ -257,6 +263,7 @@
"language/zh": "चीनी (सरलीकृत)",
"language/zh-TW": "चीनी (पारंपरिक)",
"Leave Channel": "चैनल छोड़ें",
+ "Left channel": "चैनल छोड़ दिया गया",
"Let others add options": "दूसरों को विकल्प जोड़ने दें",
"Limit votes per person": "प्रति व्यक्ति वोट सीमित करें",
"Link": "लिंक",
@@ -277,7 +284,9 @@
"Message deleted": "मैसेज हटा दिया गया",
"Message failed to send": "संदेश भेजने में विफल",
"Message has been successfully flagged": "मैसेज को फ्लैग कर दिया गया है",
+ "Message marked as unread": "संदेश को अपठित के रूप में चिह्नित किया गया",
"Message pinned": "संदेश पिन किया गया",
+ "Message unpinned": "संदेश अनपिन किया गया",
"Message was blocked by moderation policies": "संदेश को मॉडरेशन नीतियों द्वारा ब्लॉक कर दिया गया है",
"Messages have been marked unread.": "संदेशों को अपठित चिह्नित किया गया है।",
"Missing permissions to upload the attachment": "अटैचमेंट अपलोड करने के लिए अनुमतियां गायब",
@@ -431,6 +440,8 @@
"Upload error": "अपलोड त्रुटि",
"Upload failed": "अपलोड विफल",
"Upload type: \"{{ type }}\" is not allowed": "अपलोड प्रकार: \"{{ type }}\" की अनुमति नहीं है",
+ "User blocked": "उपयोगकर्ता अवरुद्ध किया गया",
+ "User unblocked": "उपयोगकर्ता अनब्लॉक किया गया",
"User uploaded content": "उपयोगकर्ता अपलोड की गई सामग्री",
"Video": "वीडियो",
"videoCount_one": "1 वीडियो",
@@ -450,6 +461,7 @@
"Vote ended": "मतदान समाप्त",
"Votes": "वोट",
"Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें",
+ "Waiting for network…": "नेटवर्क की प्रतीक्षा…",
"You": "आप",
"You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं"
}
diff --git a/src/i18n/it.json b/src/i18n/it.json
index a9d8407317..2d6b1d9ce8 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -123,7 +123,13 @@
"Block User": "Blocca utente",
"Cancel": "Annulla",
"Cannot seek in the recording": "Impossibile cercare nella registrazione",
+ "Channel archived": "Canale archiviato",
"Channel Missing": "Il canale non esiste",
+ "Channel muted": "Canale silenziato",
+ "Channel pinned": "Canale fissato",
+ "Channel unarchived": "Archiviazione del canale annullata",
+ "Channel unmuted": "Canale non più silenziato",
+ "Channel unpinned": "Canale rimosso dai fissati",
"Channels": "Canali",
"Chats": "Chat",
"Choose between 2 to 10 options": "Scegli tra 2 e 10 opzioni",
@@ -266,6 +272,7 @@
"language/zh": "Cinese (semplificato)",
"language/zh-TW": "Cinese (tradizionale)",
"Leave Channel": "Lascia il canale",
+ "Left channel": "Canale lasciato",
"Let others add options": "Lascia che altri aggiungano opzioni",
"Limit votes per person": "Limita i voti per persona",
"Link": "Collegamento",
@@ -287,7 +294,9 @@
"Message deleted": "Messaggio cancellato",
"Message failed to send": "Invio del messaggio non riuscito",
"Message has been successfully flagged": "Il messaggio è stato segnalato con successo",
+ "Message marked as unread": "Messaggio contrassegnato come non letto",
"Message pinned": "Messaggio bloccato",
+ "Message unpinned": "Messaggio rimosso dai fissati",
"Message was blocked by moderation policies": "Il messaggio è stato bloccato dalle politiche di moderazione",
"Messages have been marked unread.": "I messaggi sono stati contrassegnati come non letti.",
"Missing permissions to upload the attachment": "Autorizzazioni mancanti per caricare l'allegato",
@@ -446,6 +455,8 @@
"Upload error": "Errore di caricamento",
"Upload failed": "Caricamento non riuscito",
"Upload type: \"{{ type }}\" is not allowed": "Tipo di caricamento: \"{{ type }}\" non è consentito",
+ "User blocked": "Utente bloccato",
+ "User unblocked": "Utente sbloccato",
"User uploaded content": "Contenuto caricato dall'utente",
"Video": "Video",
"videoCount_one": "Video",
@@ -468,6 +479,7 @@
"Vote ended": "Voto terminato",
"Votes": "Voti",
"Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati",
+ "Waiting for network…": "In attesa della rete…",
"You": "Tu",
"You've reached the maximum number of files": "Hai raggiunto il numero massimo di file"
}
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index 525204ee79..7c1b211667 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -114,7 +114,13 @@
"Block User": "ユーザーをブロック",
"Cancel": "キャンセル",
"Cannot seek in the recording": "録音中にシークできません",
+ "Channel archived": "チャンネルをアーカイブしました",
"Channel Missing": "チャネルがありません",
+ "Channel muted": "チャンネルをミュートしました",
+ "Channel pinned": "チャンネルをピン留めしました",
+ "Channel unarchived": "チャンネルのアーカイブを解除しました",
+ "Channel unmuted": "チャンネルのミュートを解除しました",
+ "Channel unpinned": "チャンネルのピン留めを解除しました",
"Channels": "チャンネル",
"Chats": "チャット",
"Choose between 2 to 10 options": "2〜10の選択肢から選ぶ",
@@ -253,6 +259,7 @@
"language/zh": "中国語(簡体字)",
"language/zh-TW": "中国語(繁体字)",
"Leave Channel": "チャンネルを退出",
+ "Left channel": "チャンネルを退出しました",
"Let others add options": "他の人が選択肢を追加できるようにする",
"Limit votes per person": "1人あたりの投票数を制限する",
"Link": "リンク",
@@ -272,7 +279,9 @@
"Message deleted": "メッセージが削除されました",
"Message failed to send": "メッセージの送信に失敗しました",
"Message has been successfully flagged": "メッセージに正常にフラグが付けられました",
+ "Message marked as unread": "メッセージを未読にしました",
"Message pinned": "メッセージにピンが付けられました",
+ "Message unpinned": "メッセージのピン留めを解除しました",
"Message was blocked by moderation policies": "メッセージはモデレーションポリシーによってブロックされました",
"Messages have been marked unread.": "メッセージは未読としてマークされました。",
"Missing permissions to upload the attachment": "添付ファイルをアップロードするための許可がありません",
@@ -424,6 +433,8 @@
"Upload error": "アップロードエラー",
"Upload failed": "アップロードに失敗しました",
"Upload type: \"{{ type }}\" is not allowed": "アップロードタイプ:\"{{ type }}\"は許可されていません",
+ "User blocked": "ユーザーをブロックしました",
+ "User unblocked": "ユーザーのブロックを解除しました",
"User uploaded content": "ユーザーがアップロードしたコンテンツ",
"Video": "動画",
"videoCount_other": "{{ count }}件の動画",
@@ -441,6 +452,7 @@
"Vote ended": "投票が終了しました",
"Votes": "投票",
"Wait until all attachments have uploaded": "すべての添付ファイルがアップロードされるまでお待ちください",
+ "Waiting for network…": "ネットワークを待機中…",
"You": "あなた",
"You've reached the maximum number of files": "ファイルの最大数に達しました"
}
diff --git a/src/i18n/ko.json b/src/i18n/ko.json
index e9d3e6357f..601798b16b 100644
--- a/src/i18n/ko.json
+++ b/src/i18n/ko.json
@@ -114,7 +114,13 @@
"Block User": "사용자 차단",
"Cancel": "취소",
"Cannot seek in the recording": "녹음에서 찾을 수 없습니다",
+ "Channel archived": "채널이 보관됨",
"Channel Missing": "채널 누락",
+ "Channel muted": "채널이 음소거됨",
+ "Channel pinned": "채널이 고정됨",
+ "Channel unarchived": "채널 보관이 해제됨",
+ "Channel unmuted": "채널 음소거가 해제됨",
+ "Channel unpinned": "채널 고정이 해제됨",
"Channels": "채널",
"Chats": "채팅",
"Choose between 2 to 10 options": "2~10개의 선택지 중에서 선택",
@@ -253,6 +259,7 @@
"language/zh": "중국어(간체)",
"language/zh-TW": "중국어(번체)",
"Leave Channel": "채널 나가기",
+ "Left channel": "채널을 나갔습니다",
"Let others add options": "다른 사람이 선택지를 추가할 수 있도록 허용",
"Limit votes per person": "1인당 투표 수 제한",
"Link": "링크",
@@ -272,7 +279,9 @@
"Message deleted": "메시지가 삭제되었습니다.",
"Message failed to send": "메시지 전송 실패",
"Message has been successfully flagged": "메시지에 플래그가 지정되었습니다.",
+ "Message marked as unread": "메시지를 읽지 않음으로 표시했습니다",
"Message pinned": "메시지 핀했습니다",
+ "Message unpinned": "메시지 고정이 해제됨",
"Message was blocked by moderation policies": "메시지가 관리 정책에 의해 차단되었습니다.",
"Messages have been marked unread.": "메시지가 읽지 않음으로 표시되었습니다.",
"Missing permissions to upload the attachment": "첨부 파일을 업로드하려면 권한이 필요합니다",
@@ -424,6 +433,8 @@
"Upload error": "업로드 오류",
"Upload failed": "업로드에 실패했습니다",
"Upload type: \"{{ type }}\" is not allowed": "업로드 유형: \"{{ type }}\"은(는) 허용되지 않습니다.",
+ "User blocked": "사용자가 차단됨",
+ "User unblocked": "사용자 차단이 해제됨",
"User uploaded content": "사용자 업로드 콘텐츠",
"Video": "동영상",
"videoCount_other": "동영상 {{ count }}개",
@@ -441,6 +452,7 @@
"Vote ended": "투표 종료",
"Votes": "투표",
"Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다립니다.",
+ "Waiting for network…": "네트워크 대기 중…",
"You": "당신",
"You've reached the maximum number of files": "최대 파일 수에 도달했습니다."
}
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index 77bd5dc26d..0bc74d805a 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -115,7 +115,13 @@
"Block User": "Gebruiker blokkeren",
"Cancel": "Annuleer",
"Cannot seek in the recording": "Kan niet zoeken in de opname",
+ "Channel archived": "Kanaal gearchiveerd",
"Channel Missing": "Kanaal niet gevonden",
+ "Channel muted": "Kanaal gedempt",
+ "Channel pinned": "Kanaal vastgezet",
+ "Channel unarchived": "Kanaal uit archief gehaald",
+ "Channel unmuted": "Dempen van kanaal opgeheven",
+ "Channel unpinned": "Kanaal losgemaakt",
"Channels": "Kanalen",
"Chats": "Chats",
"Choose between 2 to 10 options": "Kies tussen 2 en 10 opties",
@@ -256,6 +262,7 @@
"language/zh": "Chinees (vereenvoudigd)",
"language/zh-TW": "Chinees (traditioneel)",
"Leave Channel": "Kanaal verlaten",
+ "Left channel": "Kanaal verlaten",
"Let others add options": "Laat anderen opties toevoegen",
"Limit votes per person": "Stemmen per persoon beperken",
"Link": "Link",
@@ -276,7 +283,9 @@
"Message deleted": "Bericht verwijderd",
"Message failed to send": "Bericht kon niet worden verzonden",
"Message has been successfully flagged": "Bericht is succesvol gemarkeerd",
+ "Message marked as unread": "Bericht gemarkeerd als ongelezen",
"Message pinned": "Bericht vastgezet",
+ "Message unpinned": "Bericht losgemaakt",
"Message was blocked by moderation policies": "Bericht is geblokkeerd door moderatiebeleid",
"Messages have been marked unread.": "Berichten zijn gemarkeerd als ongelezen.",
"Missing permissions to upload the attachment": "Missende toestemmingen om de bijlage te uploaden",
@@ -432,6 +441,8 @@
"Upload error": "Uploadfout",
"Upload failed": "Upload mislukt",
"Upload type: \"{{ type }}\" is not allowed": "Uploadtype: \"{{ type }}\" is niet toegestaan",
+ "User blocked": "Gebruiker geblokkeerd",
+ "User unblocked": "Gebruiker gedeblokkeerd",
"User uploaded content": "Gebruikersgeüploade inhoud",
"Video": "Video",
"videoCount_one": "Video",
@@ -451,6 +462,7 @@
"Vote ended": "Stemmen beëindigd",
"Votes": "Stemmen",
"Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload",
+ "Waiting for network…": "Wachten op netwerk…",
"You": "Jij",
"You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt"
}
diff --git a/src/i18n/pt.json b/src/i18n/pt.json
index 663bca08ad..e47de7aeb5 100644
--- a/src/i18n/pt.json
+++ b/src/i18n/pt.json
@@ -123,7 +123,13 @@
"Block User": "Bloquear usuário",
"Cancel": "Cancelar",
"Cannot seek in the recording": "Não é possível buscar na gravação",
+ "Channel archived": "Canal arquivado",
"Channel Missing": "Canal ausente",
+ "Channel muted": "Canal silenciado",
+ "Channel pinned": "Canal fixado",
+ "Channel unarchived": "Canal desarquivado",
+ "Channel unmuted": "Silêncio do canal desativado",
+ "Channel unpinned": "Canal desafixado",
"Channels": "Canais",
"Chats": "Conversas",
"Choose between 2 to 10 options": "Escolha entre 2 a 10 opções",
@@ -266,6 +272,7 @@
"language/zh": "Chinês (simplificado)",
"language/zh-TW": "Chinês (tradicional)",
"Leave Channel": "Sair do canal",
+ "Left channel": "Canal abandonado",
"Let others add options": "Permitir que outros adicionem opções",
"Limit votes per person": "Limitar votos por pessoa",
"Link": "Link",
@@ -287,7 +294,9 @@
"Message deleted": "Mensagem apagada",
"Message failed to send": "Falha ao enviar a mensagem",
"Message has been successfully flagged": "A mensagem foi reportada com sucesso",
+ "Message marked as unread": "Mensagem marcada como não lida",
"Message pinned": "Mensagem fixada",
+ "Message unpinned": "Mensagem desafixada",
"Message was blocked by moderation policies": "A mensagem foi bloqueada pelas políticas de moderação",
"Messages have been marked unread.": "Mensagens foram marcadas como não lidas.",
"Missing permissions to upload the attachment": "Faltando permissões para enviar o anexo",
@@ -446,6 +455,8 @@
"Upload error": "Erro no envio",
"Upload failed": "Falha no envio",
"Upload type: \"{{ type }}\" is not allowed": "Tipo de upload: \"{{ type }}\" não é permitido",
+ "User blocked": "Usuário bloqueado",
+ "User unblocked": "Usuário desbloqueado",
"User uploaded content": "Conteúdo enviado pelo usuário",
"Video": "Vídeo",
"videoCount_one": "Vídeo",
@@ -468,6 +479,7 @@
"Vote ended": "Votação encerrada",
"Votes": "Votos",
"Wait until all attachments have uploaded": "Espere até que todos os anexos tenham sido carregados",
+ "Waiting for network…": "Aguardando rede…",
"You": "Você",
"You've reached the maximum number of files": "Você atingiu o número máximo de arquivos"
}
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index aa0b92f294..3f9569b1ab 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -132,7 +132,13 @@
"Block User": "Заблокировать пользователя",
"Cancel": "Отмена",
"Cannot seek in the recording": "Невозможно осуществить поиск в записи",
+ "Channel archived": "Канал в архиве",
"Channel Missing": "Канал не найден",
+ "Channel muted": "Канал заглушён",
+ "Channel pinned": "Канал закреплён",
+ "Channel unarchived": "Канал извлечён из архива",
+ "Channel unmuted": "Заглушение канала снято",
+ "Channel unpinned": "Канал откреплён",
"Channels": "Каналы",
"Chats": "Чаты",
"Choose between 2 to 10 options": "Выберите от 2 до 10 вариантов",
@@ -280,6 +286,7 @@
"language/zh": "Китайский (упрощённый)",
"language/zh-TW": "Китайский (традиционный)",
"Leave Channel": "Покинуть канал",
+ "Left channel": "Канал покинут",
"Let others add options": "Разрешить другим добавлять варианты",
"Limit votes per person": "Ограничить голоса на человека",
"Link": "Линк",
@@ -302,7 +309,9 @@
"Message deleted": "Сообщение удалено",
"Message failed to send": "Не удалось отправить сообщение",
"Message has been successfully flagged": "Жалоба на сообщение была принята",
+ "Message marked as unread": "Сообщение помечено как непрочитанное",
"Message pinned": "Сообщение закреплено",
+ "Message unpinned": "Сообщение откреплено",
"Message was blocked by moderation policies": "Сообщение было заблокировано модерацией",
"Messages have been marked unread.": "Сообщения были отмечены как непрочитанные.",
"Missing permissions to upload the attachment": "Отсутствуют разрешения для загрузки вложения",
@@ -466,6 +475,8 @@
"Upload error": "Ошибка загрузки",
"Upload failed": "Загрузка не удалась",
"Upload type: \"{{ type }}\" is not allowed": "Тип загрузки: \"{{ type }}\" не разрешен",
+ "User blocked": "Пользователь заблокирован",
+ "User unblocked": "Пользователь разблокирован",
"User uploaded content": "Пользователь загрузил контент",
"Video": "Видео",
"videoCount_one": "{{ count }} видео",
@@ -491,6 +502,7 @@
"Vote ended": "Голосование завершено",
"Votes": "Голоса",
"Wait until all attachments have uploaded": "Подождите, пока все вложения загрузятся",
+ "Waiting for network…": "Ожидание сети…",
"You": "Вы",
"You've reached the maximum number of files": "Вы достигли максимального количества файлов"
}
diff --git a/src/i18n/tr.json b/src/i18n/tr.json
index c792c01d3e..94db223669 100644
--- a/src/i18n/tr.json
+++ b/src/i18n/tr.json
@@ -115,7 +115,13 @@
"Block User": "Kullanıcıyı engelle",
"Cancel": "İptal",
"Cannot seek in the recording": "Kayıtta arama yapılamıyor",
+ "Channel archived": "Kanal arşivlendi",
"Channel Missing": "Kanal bulunamıyor",
+ "Channel muted": "Kanal sessize alındı",
+ "Channel pinned": "Kanal sabitlendi",
+ "Channel unarchived": "Kanal arşivden çıkarıldı",
+ "Channel unmuted": "Kanal sesi açıldı",
+ "Channel unpinned": "Kanal sabitlemesi kaldırıldı",
"Channels": "Kanallar",
"Chats": "Sohbetler",
"Choose between 2 to 10 options": "2 ile 10 seçenek arasından seçin",
@@ -256,6 +262,7 @@
"language/zh": "Çince (basitleştirilmiş)",
"language/zh-TW": "Çince (geleneksel)",
"Leave Channel": "Kanaldan ayrıl",
+ "Left channel": "Kanaldan ayrıldınız",
"Let others add options": "Başkalarının seçenek eklemesine izin ver",
"Limit votes per person": "Kişi başına oy sınırı",
"Link": "Bağlantı",
@@ -276,7 +283,9 @@
"Message deleted": "Mesaj silindi",
"Message failed to send": "Mesaj gönderilemedi",
"Message has been successfully flagged": "Mesaj başarıyla bayraklandı",
+ "Message marked as unread": "Mesaj okunmadı olarak işaretlendi",
"Message pinned": "Mesaj sabitlendi",
+ "Message unpinned": "Mesaj sabitlemesi kaldırıldı",
"Message was blocked by moderation policies": "Mesaj moderasyon politikaları tarafından engellendi",
"Messages have been marked unread.": "Mesajlar okunmamış olarak işaretlendi.",
"Missing permissions to upload the attachment": "Ek yüklemek için izinler eksik",
@@ -430,6 +439,8 @@
"Upload error": "Yükleme hatası",
"Upload failed": "Yükleme başarısız oldu",
"Upload type: \"{{ type }}\" is not allowed": "Yükleme türü: \"{{ type }}\" izin verilmez",
+ "User blocked": "Kullanıcı engellendi",
+ "User unblocked": "Kullanıcının engeli kaldırıldı",
"User uploaded content": "Kullanıcı tarafından yüklenen içerik",
"Video": "Video",
"videoCount_one": "Video",
@@ -449,6 +460,7 @@
"Vote ended": "Oylama sona erdi",
"Votes": "Oylar",
"Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin",
+ "Waiting for network…": "Ağ bekleniyor…",
"You": "Sen",
"You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız"
}