diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock
index c620d87751..eb9195ebfd 100644
--- a/examples/SampleApp/yarn.lock
+++ b/examples/SampleApp/yarn.lock
@@ -8291,10 +8291,10 @@ stream-chat-react-native-core@8.1.0:
version "0.0.0"
uid ""
-stream-chat@^9.43.1:
- version "9.43.1"
- resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.1.tgz#5b2cccdd95ce92cc44c6691c527eeee271ce37bd"
- integrity sha512-lP1B3ulv2B20tqbn0xWUaVuKgBPAtgiKRGTBgmZsAIcOKDziR0xbYmZuC8zo9+L6yPh3euSdbF5w+CQ/Rn1FiQ==
+stream-chat@^9.43.2:
+ version "9.43.2"
+ resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.2.tgz#2b53af3a4ce00c90f531cb44f01b6e09a91bfe13"
+ integrity sha512-+o1f8RfqqeBq7ShH74TyZDei4+8UWagKFz2xYhmANHCNl2bNPuLIAaDbV7sK3Liw9eg/26Kml/gUgGoSLUwZVA==
dependencies:
"@types/jsonwebtoken" "^9.0.8"
"@types/ws" "^8.5.14"
diff --git a/package/package.json b/package/package.json
index 21b1d25e0d..7a1a6274dd 100644
--- a/package/package.json
+++ b/package/package.json
@@ -83,7 +83,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
- "stream-chat": "^9.43.1",
+ "stream-chat": "^9.43.2",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
index 6529814903..91610b5798 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
@@ -3,7 +3,7 @@ import { Linking, Platform, Pressable, StyleSheet, Text } from 'react-native';
import { FlatList } from 'react-native-gesture-handler';
-import { CommandSearchSource, CommandSuggestion } from 'stream-chat';
+import { CommandSearchSource, CommandSuggestion, notifyCommandDisabled } from 'stream-chat';
import { AttachmentMediaPicker } from './AttachmentMediaPicker/AttachmentMediaPicker';
@@ -58,7 +58,7 @@ export const AttachmentCommandNativePickerItem = ({ item }: { item: CommandSugge
const { close } = useBottomSheetContext();
const handlePress = useCallback(() => {
- if (messageComposer.isCommandDisabled(item)) {
+ if (notifyCommandDisabled(messageComposer, item)) {
return;
}
@@ -77,7 +77,7 @@ export const AttachmentCommandPickerItem = ({ item }: { item: CommandSuggestion
const { inputBoxRef } = useMessageInputContext();
const handlePress = useCallback(() => {
- if (messageComposer.isCommandDisabled(item)) {
+ if (notifyCommandDisabled(messageComposer, item)) {
return;
}
diff --git a/package/src/components/AttachmentPicker/components/__tests__/AttachmentPickerContent.test.tsx b/package/src/components/AttachmentPicker/components/__tests__/AttachmentPickerContent.test.tsx
index 977f9db81f..4fd7491891 100644
--- a/package/src/components/AttachmentPicker/components/__tests__/AttachmentPickerContent.test.tsx
+++ b/package/src/components/AttachmentPicker/components/__tests__/AttachmentPickerContent.test.tsx
@@ -1,27 +1,31 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react-native';
-import type { CommandSuggestion } from 'stream-chat';
-
-import {
- AttachmentCommandNativePickerItem,
- AttachmentCommandPickerItem,
-} from '../AttachmentPickerContent';
+import { type CommandSuggestion, notifyCommandDisabled } from 'stream-chat';
jest.mock('stream-chat', () => ({
CommandSearchSource: jest.fn(() => ({
query: jest.fn(() => ({ items: [] })),
})),
+ notifyCommandDisabled: jest.fn(),
}));
+import {
+ AttachmentCommandNativePickerItem,
+ AttachmentCommandPickerItem,
+} from '../AttachmentPickerContent';
+
jest.mock('../AttachmentMediaPicker/AttachmentMediaPicker', () => ({
AttachmentMediaPicker: () => null,
}));
+const mockNotifyCommandDisabled = jest.mocked(notifyCommandDisabled);
const mockClose = jest.fn((callback?: () => void) => callback?.());
const mockFocus = jest.fn();
-const mockIsCommandDisabled = jest.fn();
const mockSetCommand = jest.fn();
+const mockMessageComposer = {
+ textComposer: { setCommand: mockSetCommand },
+};
jest.mock('../../../../contexts', () => ({
useAttachmentPickerContext: jest.fn(() => ({
@@ -30,10 +34,7 @@ jest.mock('../../../../contexts', () => ({
useBottomSheetContext: jest.fn(() => ({
close: mockClose,
})),
- useMessageComposer: jest.fn(() => ({
- isCommandDisabled: mockIsCommandDisabled,
- textComposer: { setCommand: mockSetCommand },
- })),
+ useMessageComposer: jest.fn(() => mockMessageComposer),
useMessageInputContext: jest.fn(() => ({
inputBoxRef: { current: { focus: mockFocus } },
})),
@@ -73,30 +74,30 @@ describe('AttachmentPickerContent commands', () => {
beforeEach(() => {
mockClose.mockClear();
mockFocus.mockClear();
- mockIsCommandDisabled.mockReset();
+ mockNotifyCommandDisabled.mockReset();
mockSetCommand.mockClear();
});
- it('does not focus the input when a disabled command is pressed', () => {
- mockIsCommandDisabled.mockReturnValue(true);
+ it('does not focus the input when a disabled command notification is emitted', () => {
+ mockNotifyCommandDisabled.mockReturnValue(true);
render();
fireEvent.press(screen.getByText('ban'));
- expect(mockIsCommandDisabled).toHaveBeenCalledWith(command);
+ expect(mockNotifyCommandDisabled).toHaveBeenCalledWith(mockMessageComposer, command);
expect(mockSetCommand).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
});
- it('does not close the picker or focus the input when a disabled command is pressed in native picker mode', () => {
- mockIsCommandDisabled.mockReturnValue(true);
+ it('does not close the picker or focus the input when a disabled command notification is emitted in native picker mode', () => {
+ mockNotifyCommandDisabled.mockReturnValue(true);
render();
fireEvent.press(screen.getByText('ban'));
- expect(mockIsCommandDisabled).toHaveBeenCalledWith(command);
+ expect(mockNotifyCommandDisabled).toHaveBeenCalledWith(mockMessageComposer, command);
expect(mockSetCommand).not.toHaveBeenCalled();
expect(mockClose).not.toHaveBeenCalled();
expect(mockFocus).not.toHaveBeenCalled();
diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx
index 4f6eeea3bf..219824331e 100644
--- a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx
+++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx
@@ -1,6 +1,9 @@
+import React, { PropsWithChildren } from 'react';
+
import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native';
import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat';
+import { ChatProvider } from '../../../contexts/chatContext/ChatContext';
import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel';
import { useMockedApis } from '../../../mock-builders/api/useMockedApis';
import { generateChannelResponse } from '../../../mock-builders/generator/channel';
@@ -11,6 +14,12 @@ import { channelInitialState } from '../hooks/useChannelDataState';
import * as ChannelStateHooks from '../hooks/useChannelDataState';
import { useMessageListPagination } from '../hooks/useMessageListPagination';
+const createChatWrapper =
+ (client: StreamChat) =>
+ ({ children }: PropsWithChildren) => (
+ {children}
+ );
+
describe('useMessageListPagination', () => {
let chatClient: StreamChat;
let channel: ChannelType;
@@ -618,7 +627,7 @@ describe('useMessageListPagination', () => {
};
// Mock query if needed
- const queryMock = jest.fn();
+ const queryMock = jest.fn().mockResolvedValue({ messages: [] });
channel.query = queryMock as unknown as typeof channel.query;
// Set up mocks
@@ -626,9 +635,12 @@ describe('useMessageListPagination', () => {
mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock });
const setChannelUnreadStateMock = jest.fn();
const setTargetedMessageIdMock = jest.fn((message) => message);
+ const addNotificationSpy = jest.spyOn(chatClient.notifications, 'add');
// Render hook
- const { result } = renderHook(() => useMessageListPagination({ channel }));
+ const { result } = renderHook(() => useMessageListPagination({ channel }), {
+ wrapper: createChatWrapper(chatClient),
+ });
// Act
await act(async () => {
@@ -642,6 +654,21 @@ describe('useMessageListPagination', () => {
// Assert
await waitFor(() => {
expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls);
+ if (expectedQueryCalls) {
+ expect(addNotificationSpy).toHaveBeenCalledWith({
+ message: 'Failed to jump to the first unread message',
+ options: {
+ originalError: expect.any(Error),
+ severity: 'error',
+ tags: ['target:channel'],
+ type: 'channel:jumpToFirstUnread:failed',
+ },
+ origin: {
+ context: { feature: 'jumpToFirstUnread' },
+ emitter: 'Channel',
+ },
+ });
+ }
expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(
expectedJumpToMessageFinishedCalls,
);
diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx
index c16315fe66..a7bde12897 100644
--- a/package/src/components/Channel/hooks/useMessageListPagination.tsx
+++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx
@@ -6,8 +6,10 @@ import { Channel, ChannelState, MessageResponse } from 'stream-chat';
import { useChannelMessageDataState } from './useChannelDataState';
import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext';
+import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext';
import { useStableCallback } from '../../../hooks';
import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils';
+import { useNotificationApi } from '../../Notifications';
const defaultDebounceInterval = 500;
const debounceOptions = {
@@ -22,6 +24,8 @@ const debounceOptions = {
* @param channel The channel object for which the message list pagination is being handled.
*/
export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
+ const { addNotification } = useNotificationApi();
+ const { t } = useTranslationContext();
const {
copyMessagesStateFromChannel,
jumpToLatestMessage,
@@ -180,6 +184,18 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
},
);
+ const notifyJumpToFirstUnreadError = useStableCallback((error: unknown) => {
+ addNotification({
+ context: { feature: 'jumpToFirstUnread' },
+ emitter: 'Channel',
+ error: error instanceof Error ? error : undefined,
+ message: t('Failed to jump to the first unread message'),
+ severity: 'error',
+ targetPanels: ['channel'],
+ type: 'channel:jumpToFirstUnread:failed',
+ });
+ });
+
/**
* Loads channel at first unread message.
*/
@@ -225,7 +241,7 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
} catch (error) {
setLoading(false);
loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState);
- console.log('Loading channel at first unread message failed with error:', error);
+ notifyJumpToFirstUnreadError(error);
return;
}
@@ -275,7 +291,7 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
} catch (error) {
setLoading(false);
loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
- console.log('Loading channel at first unread message failed with error:', error);
+ notifyJumpToFirstUnreadError(error);
return;
}
}
@@ -296,7 +312,7 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
setTargetedMessage(firstUnreadMessageId);
}
} catch (error) {
- console.log('Loading channel at first unread message failed with error:', error);
+ notifyJumpToFirstUnreadError(error);
}
},
);
diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx
index 9bd0055f59..dd59c8ed71 100644
--- a/package/src/components/ChannelList/ChannelList.tsx
+++ b/package/src/components/ChannelList/ChannelList.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
+import { StyleSheet, View } from 'react-native';
import type { FlatList } from 'react-native-gesture-handler';
import {
@@ -21,6 +22,7 @@ import {
ChannelsProvider,
} from '../../contexts/channelsContext/ChannelsContext';
import { useChatContext } from '../../contexts/chatContext/ChatContext';
+import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import { SwipeRegistryProvider } from '../../contexts/swipeableContext/SwipeRegistryContext';
import type { ChannelListEventListenerOptions } from '../../types/types';
@@ -250,6 +252,7 @@ export const ChannelList = (props: ChannelListProps) => {
const [forceUpdate, setForceUpdate] = useState(0);
const { client, enableOfflineSupport } = useChatContext();
+ const { NotificationList } = useComponentsContext();
const channelManager = useMemo(() => client.createChannelManager({}), [client]);
/**
@@ -370,8 +373,17 @@ export const ChannelList = (props: ChannelListProps) => {
return (
-
+
+
+
+
);
};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+});
diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx
new file mode 100644
index 0000000000..e885ee44d2
--- /dev/null
+++ b/package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx
@@ -0,0 +1,123 @@
+import React, { PropsWithChildren } from 'react';
+
+import { act, renderHook } from '@testing-library/react-native';
+import type { Channel } from 'stream-chat';
+
+import { ChatProvider } from '../../../../contexts/chatContext/ChatContext';
+import { useChannelActions } from '../useChannelActions';
+
+const createWrapper =
+ (client: unknown) =>
+ ({ children }: PropsWithChildren) => (
+ {children}
+ );
+
+const createClient = () => ({
+ blockUser: jest.fn(),
+ notifications: {
+ add: jest.fn(),
+ remove: jest.fn(),
+ startTimeout: jest.fn(),
+ },
+ muteUser: jest.fn(),
+ unBlockUser: jest.fn(),
+ unmuteUser: jest.fn(),
+ userID: 'current-user-id',
+});
+
+const createChannel = (client: ReturnType) =>
+ ({
+ archive: jest.fn(),
+ getClient: () => client,
+ mute: jest.fn(),
+ pin: jest.fn(),
+ removeMembers: jest.fn(),
+ state: {
+ members: {
+ current: { user: { id: 'current-user-id' } },
+ other: { user: { id: 'other-user-id', name: 'Other User' } },
+ },
+ },
+ unarchive: jest.fn(),
+ unmute: jest.fn(),
+ unpin: jest.fn(),
+ }) as unknown as Channel;
+
+describe('useChannelActions', () => {
+ it('notifies when channel mute succeeds', async () => {
+ const client = createClient();
+ const channel = createChannel(client);
+ const { result } = renderHook(() => useChannelActions(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ await act(async () => {
+ await result.current.muteChannel();
+ });
+
+ expect(channel.mute).toHaveBeenCalledTimes(1);
+ expect(client.notifications.add).toHaveBeenCalledWith({
+ message: 'Channel muted',
+ options: {
+ severity: 'success',
+ type: 'api:channel:mute:success',
+ },
+ origin: {
+ context: { channel },
+ emitter: 'ChannelActions',
+ },
+ });
+ });
+
+ it('notifies when channel mute fails', async () => {
+ const error = new Error('mute failed');
+ const client = createClient();
+ const channel = createChannel(client);
+ jest.mocked(channel.mute).mockRejectedValue(error);
+ const { result } = renderHook(() => useChannelActions(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ await act(async () => {
+ await result.current.muteChannel();
+ });
+
+ expect(client.notifications.add).toHaveBeenCalledWith({
+ message: 'Failed to update channel mute status',
+ options: {
+ originalError: error,
+ severity: 'error',
+ type: 'api:channel:mute:failed',
+ },
+ origin: {
+ context: { channel },
+ emitter: 'ChannelActions',
+ },
+ });
+ });
+
+ it('notifies when a direct channel user is blocked', async () => {
+ const client = createClient();
+ const channel = createChannel(client);
+ const { result } = renderHook(() => useChannelActions(channel), {
+ wrapper: createWrapper(client),
+ });
+
+ await act(async () => {
+ await result.current.blockUser();
+ });
+
+ expect(client.blockUser).toHaveBeenCalledWith('other-user-id');
+ expect(client.notifications.add).toHaveBeenCalledWith({
+ message: 'User blocked',
+ options: {
+ severity: 'success',
+ type: 'api:user:block:success',
+ },
+ origin: {
+ context: { channel },
+ emitter: 'ChannelActions',
+ },
+ });
+ });
+});
diff --git a/package/src/components/ChannelList/hooks/useChannelActions.ts b/package/src/components/ChannelList/hooks/useChannelActions.ts
index 2453c86c84..cb3433f955 100644
--- a/package/src/components/ChannelList/hooks/useChannelActions.ts
+++ b/package/src/components/ChannelList/hooks/useChannelActions.ts
@@ -2,8 +2,9 @@ import { useMemo } from 'react';
import { Channel } from 'stream-chat';
-import { useChatContext } from '../../../contexts';
+import { useChatContext, useTranslationContext } from '../../../contexts';
import { useStableCallback } from '../../../hooks';
+import { useNotificationApi } from '../../Notifications';
export type ChannelActions = {
archive: () => Promise;
@@ -27,8 +28,20 @@ export const getOtherUserInDirectChannel = (channel: Channel) => {
: undefined;
};
+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 useChannelActions = (channel: Channel) => {
const { client } = useChatContext();
+ const { addNotification } = useNotificationApi();
+ const { t } = useTranslationContext();
const ownUserId = client.userID;
const pin = useStableCallback(async () => {
@@ -37,8 +50,22 @@ export const useChannelActions = (channel: Channel) => {
return;
}
await channel.pin();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel pinned'),
+ severity: 'success',
+ type: 'api:channel:pin:success',
+ });
} catch (error) {
- console.log('Error pinning channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel pinned status'),
+ severity: 'error',
+ type: 'api:channel:pin:failed',
+ });
}
});
@@ -48,8 +75,22 @@ export const useChannelActions = (channel: Channel) => {
return;
}
await channel.unpin();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel unpinned'),
+ severity: 'success',
+ type: 'api:channel:unpin:success',
+ });
} catch (error) {
- console.log('Error unpinning channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel pinned status'),
+ severity: 'error',
+ type: 'api:channel:pin:failed',
+ });
}
});
@@ -59,8 +100,22 @@ export const useChannelActions = (channel: Channel) => {
return;
}
await channel.archive();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel archived'),
+ severity: 'success',
+ type: 'api:channel:archive:success',
+ });
} catch (error) {
- console.log('Error archiving channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel archive status'),
+ severity: 'error',
+ type: 'api:channel:archive:failed',
+ });
}
});
@@ -70,8 +125,22 @@ export const useChannelActions = (channel: Channel) => {
return;
}
await channel.unarchive();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel unarchived'),
+ severity: 'success',
+ type: 'api:channel:unarchive:success',
+ });
} catch (error) {
- console.log('Error unarchiving channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel archive status'),
+ severity: 'error',
+ type: 'api:channel:archive:failed',
+ });
}
});
@@ -80,7 +149,25 @@ export const useChannelActions = (channel: Channel) => {
return;
}
if (ownUserId) {
- await channel.removeMembers([ownUserId]);
+ try {
+ await channel.removeMembers([ownUserId]);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Left channel'),
+ severity: 'success',
+ type: 'api:channel:leave:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to leave channel'),
+ severity: 'error',
+ type: 'api:channel:leave:failed',
+ });
+ }
}
});
@@ -106,9 +193,25 @@ export const useChannelActions = (channel: Channel) => {
try {
if (otherUser?.user?.id) {
await client.muteUser(otherUser.user.id);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('{{ user }} has been muted', {
+ user: otherUser.user.name || otherUser.user.id,
+ }),
+ severity: 'success',
+ type: 'api:user:mute:success',
+ });
}
} catch (error) {
- console.log('Error muting user', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Error muting a user ...'),
+ severity: 'error',
+ type: 'api:user:mute:failed',
+ });
}
});
@@ -122,9 +225,25 @@ export const useChannelActions = (channel: Channel) => {
try {
if (otherUser?.user?.id) {
await client.unmuteUser(otherUser.user.id);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('{{ user }} has been unmuted', {
+ user: otherUser.user.name || otherUser.user.id,
+ }),
+ severity: 'success',
+ type: 'api:user:unmute:success',
+ });
}
} catch (error) {
- console.log('Error muting user', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Error unmuting a user ...'),
+ severity: 'error',
+ type: 'api:user:unmute:failed',
+ });
}
});
@@ -135,8 +254,22 @@ export const useChannelActions = (channel: Channel) => {
try {
await channel.mute();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel muted'),
+ severity: 'success',
+ type: 'api:channel:mute:success',
+ });
} catch (error) {
- console.log('Error muting channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel mute status'),
+ severity: 'error',
+ type: 'api:channel:mute:failed',
+ });
}
});
@@ -147,8 +280,22 @@ export const useChannelActions = (channel: Channel) => {
try {
await channel.unmute();
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('Channel unmuted'),
+ severity: 'success',
+ type: 'api:channel:unmute:success',
+ });
} catch (error) {
- console.log('Error muting channel', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to update channel mute status'),
+ severity: 'error',
+ type: 'api:channel:mute:failed',
+ });
}
});
@@ -162,9 +309,23 @@ export const useChannelActions = (channel: Channel) => {
try {
if (otherUser?.user?.id) {
await client.blockUser(otherUser.user.id);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('User blocked'),
+ severity: 'success',
+ type: 'api:user:block:success',
+ });
}
} catch (error) {
- console.log('Error blocking user', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to block user'),
+ severity: 'error',
+ type: 'api:user:block:failed',
+ });
}
});
@@ -178,9 +339,23 @@ export const useChannelActions = (channel: Channel) => {
try {
if (otherUser?.user?.id) {
await client.unBlockUser(otherUser.user.id);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ message: t('User unblocked'),
+ severity: 'success',
+ type: 'api:user:unblock:success',
+ });
}
} catch (error) {
- console.log('Error unblocking user', error);
+ addNotification({
+ context: { channel },
+ emitter: 'ChannelActions',
+ error: getNotificationError(error),
+ message: t('Failed to block user'),
+ severity: 'error',
+ type: 'api:user:block:failed',
+ });
}
});
diff --git a/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx b/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx
new file mode 100644
index 0000000000..16974df1f4
--- /dev/null
+++ b/package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx
@@ -0,0 +1,128 @@
+import React, { PropsWithChildren } from 'react';
+
+import { act, renderHook } from '@testing-library/react-native';
+import type { Channel, LocalMessage, StreamChat } from 'stream-chat';
+
+import { ChannelProvider } from '../../../../contexts/channelContext/ChannelContext';
+import { ChatProvider } from '../../../../contexts/chatContext/ChatContext';
+import { useMessageActionHandlers } from '../useMessageActionHandlers';
+
+const createClient = () =>
+ ({
+ mutedUsers: [],
+ notifications: {
+ add: jest.fn(),
+ remove: jest.fn(),
+ startTimeout: jest.fn(),
+ },
+ on: jest.fn(() => ({ unsubscribe: jest.fn() })),
+ pinMessage: jest.fn(),
+ unpinMessage: jest.fn(),
+ userID: 'current-user-id',
+ }) as unknown as StreamChat & {
+ notifications: { add: jest.Mock; remove: jest.Mock; startTimeout: jest.Mock };
+ pinMessage: jest.Mock;
+ unpinMessage: jest.Mock;
+ };
+
+const createChannel = () =>
+ ({
+ markUnread: jest.fn(),
+ }) as unknown as Channel & { markUnread: jest.Mock };
+
+const createWrapper =
+ (client: StreamChat, channel: Channel) =>
+ ({ children }: PropsWithChildren) => (
+
+ {children}
+
+ );
+
+const createMessage = (overrides?: Partial) =>
+ ({
+ id: 'message-id',
+ pinned: false,
+ text: 'Message text',
+ user: { id: 'message-user-id', name: 'Message User' },
+ ...overrides,
+ }) as LocalMessage;
+
+const renderUseMessageActionHandlers = ({
+ channel = createChannel(),
+ client = createClient(),
+ message = createMessage(),
+}: {
+ channel?: Channel & { markUnread: jest.Mock };
+ client?: ReturnType;
+ message?: LocalMessage;
+} = {}) =>
+ renderHook(
+ () =>
+ useMessageActionHandlers({
+ channel,
+ client,
+ deleteMessage: jest.fn(),
+ deleteReaction: jest.fn(),
+ enforceUniqueReaction: false,
+ message,
+ retrySendMessage: jest.fn(),
+ sendReaction: jest.fn(),
+ setEditingState: jest.fn(),
+ setQuotedMessage: jest.fn(),
+ supportedReactions: [],
+ }),
+ { wrapper: createWrapper(client, channel) },
+ );
+
+describe('useMessageActionHandlers notifications', () => {
+ it('notifies when pinning a message succeeds', async () => {
+ const client = createClient();
+ const message = createMessage();
+ const { result } = renderUseMessageActionHandlers({ client, message });
+
+ await act(async () => {
+ await result.current.handleTogglePinMessage();
+ });
+
+ expect(client.pinMessage).toHaveBeenCalledWith(message, null);
+ expect(client.notifications.add).toHaveBeenCalledWith({
+ message: 'Message pinned',
+ options: {
+ severity: 'success',
+ tags: ['target:channel'],
+ type: 'api:message:pin:success',
+ },
+ origin: {
+ context: { message },
+ emitter: 'MessageActions',
+ },
+ });
+ });
+
+ it('notifies when marking a message unread fails', async () => {
+ const error = new Error('Cannot mark unread');
+ const client = createClient();
+ const channel = createChannel();
+ const message = createMessage();
+ channel.markUnread.mockRejectedValue(error);
+ const { result } = renderUseMessageActionHandlers({ channel, client, message });
+
+ await act(async () => {
+ await result.current.handleMarkUnreadMessage();
+ });
+
+ expect(client.notifications.add).toHaveBeenCalledWith({
+ message: 'Cannot mark unread',
+ options: {
+ originalError: error,
+ severity: 'error',
+ tags: ['target:channel'],
+ type: 'api:message:markUnread:failed',
+ },
+ origin: {
+ context: { message },
+ emitter: 'MessageActions',
+ },
+ });
+ });
+});
diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts
index 4a5235e33c..3cad16027a 100644
--- a/package/src/components/Message/hooks/useMessageActionHandlers.ts
+++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts
@@ -19,6 +19,20 @@ import {
} from '../../../hooks';
import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage';
import { NativeHandlers } from '../../../native';
+import { useNotificationApi } from '../../Notifications';
+
+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 useWithPortalKeyboardSafety = (
callback: (...args: T) => void,
@@ -47,6 +61,7 @@ export const useMessageActionHandlers = ({
Pick &
Pick) => {
const { t } = useTranslationContext();
+ const { addNotification } = useNotificationApi();
const handleResendMessage = useStableCallback(() => retrySendMessage(message));
const translatedMessage = useTranslatedMessage(message);
@@ -74,7 +89,25 @@ export const useMessageActionHandlers = ({
{ style: 'cancel', text: t('Cancel') },
{
onPress: async () => {
- await deleteMessage(message);
+ try {
+ await deleteMessage(message);
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('Message deleted'),
+ severity: 'success',
+ type: 'api:message:delete:success',
+ });
+ } catch (error) {
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, t('Error deleting message')),
+ severity: 'error',
+ type: 'api:message:delete:failed',
+ });
+ }
},
style: 'destructive',
text: t('Delete'),
@@ -97,10 +130,42 @@ export const useMessageActionHandlers = ({
return;
}
- if (isMuted) {
- await client.unmuteUser(message.user.id);
- } else {
- await client.muteUser(message.user.id);
+ try {
+ if (isMuted) {
+ await client.unmuteUser(message.user.id);
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('{{ user }} has been unmuted', {
+ user: message.user?.name || message.user?.id,
+ }),
+ severity: 'success',
+ type: 'api:user:unmute:success',
+ });
+ } else {
+ await client.muteUser(message.user.id);
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('{{ user }} has been muted', {
+ user: message.user?.name || message.user?.id,
+ }),
+ severity: 'success',
+ type: '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',
+ });
}
});
@@ -118,11 +183,39 @@ export const useMessageActionHandlers = ({
});
const handleTogglePinMessage = useStableCallback(async () => {
- const MessagePinnedHeaderStatus = message.pinned;
- if (!MessagePinnedHeaderStatus) {
- await client.pinMessage(message, null);
- } else {
- await client.unpinMessage(message);
+ const isPinned = !!message.pinned;
+ try {
+ if (!isPinned) {
+ await client.pinMessage(message, null);
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('Message pinned'),
+ severity: 'success',
+ type: 'api:message:pin:success',
+ });
+ } else {
+ await client.unpinMessage(message);
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('Message unpinned'),
+ severity: 'success',
+ type: 'api:message:unpin: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',
+ });
}
});
@@ -143,15 +236,22 @@ export const useMessageActionHandlers = ({
onPress: async () => {
try {
await client.flagMessage(message.id);
- Alert.alert(t('Message flagged'), t('The message has been reported to a moderator.'));
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('Message has been successfully flagged'),
+ severity: 'success',
+ type: 'api:message:flag:success',
+ });
} catch (error) {
- console.log('Error flagging message:', error);
- Alert.alert(
- t('Cannot Flag Message'),
- t(
- 'Flag action failed either due to a network issue or the message is already flagged',
- ),
- );
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ error: getNotificationError(error),
+ message: getErrorMessage(error, t('Error adding flag')),
+ severity: 'error',
+ type: 'api:message:flag:failed',
+ });
}
},
text: t('Flag'),
@@ -167,13 +267,27 @@ export const useMessageActionHandlers = ({
}
try {
await channel.markUnread({ message_id: message.id });
+ addNotification({
+ context: { message },
+ emitter: 'MessageActions',
+ message: t('Message marked as unread'),
+ severity: 'success',
+ type: 'api:message:markUnread:success',
+ });
} catch (error) {
- console.log('Error marking message as unread:', error);
- Alert.alert(
- t(
- 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
+ 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',
+ });
}
});
diff --git a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx
index 9c4ee6b996..55e650569e 100644
--- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx
+++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecorder.tsx
@@ -23,6 +23,7 @@ import { Mic } from '../../../../icons/voice';
import { NativeHandlers } from '../../../../native';
import { AudioRecorderManagerState } from '../../../../state-store/audio-recorder-manager';
import { primitives } from '../../../../theme';
+import { useNotificationApi } from '../../../Notifications';
type AudioRecorderPropsWithContext = Pick<
MessageInputContextValue,
@@ -100,9 +101,17 @@ const DeleteRecording = ({
}: {
deleteVoiceRecordingHandler: () => Promise;
}) => {
- const onDeleteVoiceRecording = () => {
+ const { addNotification } = useNotificationApi();
+ const { t } = useTranslationContext();
+ const onDeleteVoiceRecording = async () => {
NativeHandlers.triggerHaptic('impactMedium');
- deleteVoiceRecordingHandler();
+ await deleteVoiceRecordingHandler();
+ addNotification({
+ emitter: 'AudioRecorder',
+ message: t('Voice message deleted'),
+ severity: 'info',
+ type: 'audioRecording:cancel:success',
+ });
};
return (