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 (