From b623655160f1af61d990b0bd4bea2713f3c3ea7c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 11:04:25 +0200 Subject: [PATCH 1/8] feat: initial snackbar implementation --- .../components/ChannelList/ChannelList.tsx | 14 +- .../AudioRecorder/AudioRecordingButton.tsx | 36 ++- .../MessageList/MessageFlashList.tsx | 2 + .../components/MessageList/MessageList.tsx | 2 + .../__tests__/MessageList.test.tsx | 35 +++ .../components/Notifications/Notification.tsx | 227 ++++++++++++++++++ .../Notifications/NotificationList.tsx | 221 +++++++++++++++++ .../__tests__/NotificationList.test.tsx | 96 ++++++++ .../__tests__/notificationTarget.test.ts | 53 ++++ .../__tests__/useNotificationApi.test.tsx | 122 ++++++++++ .../__tests__/useNotificationTarget.test.tsx | 85 +++++++ .../components/Notifications/hooks/index.ts | 4 + .../Notifications/hooks/useNotificationApi.ts | 179 ++++++++++++++ .../hooks/useNotificationTarget.ts | 37 +++ .../Notifications/hooks/useNotifications.ts | 41 ++++ .../hooks/useSystemNotifications.ts | 31 +++ package/src/components/Notifications/index.ts | 4 + .../Notifications/notificationTarget.ts | 63 +++++ .../Notifications/notificationTranslations.ts | 142 +++++++++++ .../src/components/ThreadList/ThreadList.tsx | 15 +- package/src/components/index.ts | 2 + .../componentsContext/defaultComponents.ts | 4 + .../src/contexts/themeContext/utils/theme.ts | 26 ++ package/src/hooks/index.ts | 1 + .../src/hooks/useInAppNotificationsState.ts | 6 + package/src/i18n/en.json | 27 ++- 26 files changed, 1457 insertions(+), 18 deletions(-) create mode 100644 package/src/components/Notifications/Notification.tsx create mode 100644 package/src/components/Notifications/NotificationList.tsx create mode 100644 package/src/components/Notifications/__tests__/NotificationList.test.tsx create mode 100644 package/src/components/Notifications/__tests__/notificationTarget.test.ts create mode 100644 package/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx create mode 100644 package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx create mode 100644 package/src/components/Notifications/hooks/index.ts create mode 100644 package/src/components/Notifications/hooks/useNotificationApi.ts create mode 100644 package/src/components/Notifications/hooks/useNotificationTarget.ts create mode 100644 package/src/components/Notifications/hooks/useNotifications.ts create mode 100644 package/src/components/Notifications/hooks/useSystemNotifications.ts create mode 100644 package/src/components/Notifications/index.ts create mode 100644 package/src/components/Notifications/notificationTarget.ts create mode 100644 package/src/components/Notifications/notificationTranslations.ts 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/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx index 2b389d8ecb..f230ef8c39 100644 --- a/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx +++ b/package/src/components/MessageInput/components/AudioRecorder/AudioRecordingButton.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Alert, Linking, StyleSheet } from 'react-native'; +import { Linking, StyleSheet } from 'react-native'; import { Gesture, GestureDetector, State } from 'react-native-gesture-handler'; import Animated, { @@ -24,6 +24,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'; import { ButtonStylesConfig, useButtonStyles } from '../../../ui/Button/hooks/useButtonStyles'; import { useMicPositionContext } from '../../contexts/MicPositionContext'; @@ -92,6 +93,7 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps }, } = useTheme(); const buttonStyles = useButtonStyles(buttonStylesConfig); + const { addNotification } = useNotificationApi(); const onPressHandler = useStableCallback(() => { if (handlePress) { @@ -99,7 +101,12 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps } if (!recording) { NativeHandlers.triggerHaptic('notificationError'); - Alert.alert(t('Hold to start recording.')); + addNotification({ + emitter: 'AudioRecordingButton', + message: 'Hold to start recording.', + severity: 'warning', + type: 'validation:audio:recording:hold-required', + }); } }); @@ -115,18 +122,21 @@ export const AudioRecordingButtonWithContext = (props: AudioRecordingButtonProps } const permissionsGranted = await startVoiceRecording(); if (!permissionsGranted) { - Alert.alert(t('Please allow Audio permissions in settings.'), '', [ - { - onPress: () => { - Linking.openSettings(); + addNotification({ + actions: [ + { + handler: () => { + Linking.openSettings(); + }, + label: t('Open Settings'), }, - text: t('Open Settings'), - }, - { - text: t('Cancel'), - style: 'cancel', - }, - ]); + ], + duration: 0, + emitter: 'AudioRecordingButton', + message: 'Please allow Audio permissions in settings.', + severity: 'warning', + type: 'permission:audio:recording:blocked', + }); return; } NativeHandlers.triggerHaptic('impactHeavy'); diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 7a4cadc113..14f079adc5 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -298,6 +298,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => EmptyStateIndicator, MessageListLoadingIndicator: LoadingIndicator, NetworkDownIndicator, + NotificationList, ScrollToBottomButton, StickyHeader, TypingIndicator, @@ -1127,6 +1128,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => /> ) : null} + ); }; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index d6b7300b2f..be1bf2fb6d 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -354,6 +354,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { EmptyStateIndicator, MessageListLoadingIndicator: LoadingIndicator, NetworkDownIndicator, + NotificationList, ScrollToBottomButton, StickyHeader, TypingIndicator, @@ -1356,6 +1357,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { /> ) : null} + ); }; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index aceab99227..bfdee3cb8d 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -184,6 +184,41 @@ describe('MessageList', () => { }); }); + it('should render client notifications in the message list notification host', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: [generateMessage({ user: user1 })], + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const { getByText } = render( + + + + + + + , + ); + + act(() => { + chatClient.notifications.add({ + message: 'Message list notification', + options: { severity: 'warning' }, + origin: { emitter: 'MessageListTest' }, + }); + }); + + await waitFor(() => { + expect(getByText('Message list notification')).toBeTruthy(); + }); + }); + it('should render the is offline error', async () => { const user1 = generateUser(); const mockedChannel = generateChannelResponse({ diff --git a/package/src/components/Notifications/Notification.tsx b/package/src/components/Notifications/Notification.tsx new file mode 100644 index 0000000000..03f90e4f48 --- /dev/null +++ b/package/src/components/Notifications/Notification.tsx @@ -0,0 +1,227 @@ +import React, { type ComponentType } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { Pressable } from 'react-native-gesture-handler'; + +import type { Notification as NotificationType, NotificationSeverity } from 'stream-chat'; + +import { useNotificationApi } from './hooks/useNotificationApi'; +import { getNotificationDisplayMessage } from './notificationTranslations'; + +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { Check } from '../../icons/checkmark'; +import { Warning } from '../../icons/exclamation-triangle-fill'; +import { InfoTooltip } from '../../icons/info'; +import { Reload } from '../../icons/refresh'; +import { IconProps } from '../../icons/utils/base'; +import { NewClose } from '../../icons/xmark'; +import { primitives } from '../../theme'; + +export type NotificationEntryDirection = 'bottom' | 'left' | 'right' | 'top'; +export type NotificationTransitionState = 'enter' | 'exit'; + +export type NotificationIconProps = { + notification: NotificationType; +}; + +const IconsBySeverity: Partial>> = { + error: Warning, + info: InfoTooltip, + loading: Reload, + success: Check, + warning: Warning, +}; + +export const NotificationIcon = ({ notification }: NotificationIconProps) => { + const { + theme: { notification: notificationTheme, semantics }, + } = useTheme(); + const severity = notification.severity; + if (!severity) return null; + + const Icon = IconsBySeverity[severity]; + if (!Icon) return null; + + const color = + severity === 'error' + ? semantics.accentError + : severity === 'success' + ? semantics.accentSuccess + : severity === 'warning' + ? semantics.accentWarning + : semantics.accentPrimary; + + return ( + + + + ); +}; + +export type NotificationProps = { + notification: NotificationType; + entryDirection?: NotificationEntryDirection; + Icon?: React.ComponentType; + onDismiss?: () => void; + showClose?: boolean; + transitionState?: NotificationTransitionState; +}; + +export const Notification = ({ + Icon, + notification, + onDismiss, + showClose = false, +}: NotificationProps) => { + const { NotificationIcon: NotificationIconComponent = NotificationIcon } = useComponentsContext(); + const { removeNotification } = useNotificationApi(); + const { + theme: { notification: notificationTheme, semantics }, + } = useTheme(); + const { t } = useTranslationContext(); + const displayMessage = getNotificationDisplayMessage({ notification, t }); + const ResolvedIcon = Icon ?? NotificationIconComponent; + const isPersistent = !notification.duration; + const closeVisible = showClose || isPersistent; + + const handleDismiss = () => { + if (onDismiss) { + onDismiss(); + return; + } + + removeNotification(notification.id); + }; + + return ( + + + {ResolvedIcon ? : null} + + {displayMessage} + + + {notification.actions && notification.actions.length > 0 ? ( + + {notification.actions.map((action, index) => ( + [ + styles.actionButton, + { + backgroundColor: pressed + ? semantics.backgroundUtilityPressed + : semantics.backgroundCoreSurfaceStrong, + }, + notificationTheme.actionButton, + ]} + > + + {action.label} + + + ))} + + ) : null} + {closeVisible ? ( + [ + styles.closeButton, + pressed ? { backgroundColor: semantics.backgroundUtilityPressed } : null, + notificationTheme.closeButton, + ]} + testID='notification-close-button' + > + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + actionButton: { + borderRadius: primitives.radiusLg, + minHeight: 32, + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingXs, + }, + actionButtonText: { + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightSemiBold, + }, + actionsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: primitives.spacingXs, + marginTop: primitives.spacingXs, + }, + closeButton: { + alignItems: 'center', + borderRadius: primitives.radiusMax, + height: 32, + justifyContent: 'center', + marginLeft: primitives.spacingXs, + width: 32, + }, + container: { + alignItems: 'flex-start', + borderRadius: primitives.radiusLg, + elevation: 5, + flexDirection: 'row', + maxWidth: '100%', + paddingHorizontal: primitives.spacingSm, + paddingVertical: primitives.spacingSm, + shadowOffset: { height: 2, width: 0 }, + shadowOpacity: 0.2, + shadowRadius: 8, + }, + contentContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + gap: primitives.spacingXs, + minHeight: 32, + }, + iconContainer: { + alignItems: 'center', + height: 24, + justifyContent: 'center', + width: 24, + }, + message: { + flex: 1, + flexShrink: 1, + fontSize: primitives.typographyFontSizeSm, + fontWeight: primitives.typographyFontWeightMedium, + lineHeight: primitives.typographyLineHeightNormal, + }, +}); diff --git a/package/src/components/Notifications/NotificationList.tsx b/package/src/components/Notifications/NotificationList.tsx new file mode 100644 index 0000000000..a4904ccde0 --- /dev/null +++ b/package/src/components/Notifications/NotificationList.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + SlideInDown, + SlideInLeft, + SlideInRight, + SlideInUp, + SlideOutDown, + SlideOutLeft, + SlideOutRight, + SlideOutUp, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +import type { Notification as NotificationType } from 'stream-chat'; + +import { hasSystemNotificationTag, useNotificationApi } from './hooks/useNotificationApi'; +import { useNotifications } from './hooks/useNotifications'; +import { Notification as DefaultNotification } from './Notification'; +import type { NotificationTargetPanel } from './notificationTarget'; + +import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { primitives } from '../../theme'; + +export type NotificationListFilter = (notification: NotificationType) => boolean; +export type NotificationListEnterFrom = 'bottom' | 'left' | 'right' | 'top'; +export type NotificationListVerticalAlignment = 'bottom' | 'top'; + +export type NotificationListProps = { + enterFrom?: NotificationListEnterFrom; + filter?: NotificationListFilter; + panel?: NotificationTargetPanel; + fallbackPanel?: NotificationTargetPanel; + verticalAlignment?: NotificationListVerticalAlignment; +}; + +const ENTER_TRANSLATION = 96; +const DISMISS_DISTANCE = 80; +const DISMISS_VELOCITY = 800; + +const enteringAnimations = { + bottom: SlideInDown.duration(180), + left: SlideInLeft.duration(180), + right: SlideInRight.duration(180), + top: SlideInUp.duration(180), +} as const; + +const exitingAnimations = { + bottom: SlideOutDown.duration(180), + left: SlideOutLeft.duration(180), + right: SlideOutRight.duration(180), + top: SlideOutUp.duration(180), +} as const; + +const isEnterFrom = (value: unknown): value is NotificationListEnterFrom => + value === 'bottom' || value === 'left' || value === 'right' || value === 'top'; + +const getNotificationEnterFrom = ( + notification: NotificationType | null, + fallbackEnterFrom: NotificationListEnterFrom, +) => { + if (!notification) return fallbackEnterFrom; + + const metadataEnterFrom = notification.metadata?.entryDirection; + if (isEnterFrom(metadataEnterFrom)) return metadataEnterFrom; + + const originEnterFrom = notification.origin.context?.entryDirection; + if (isEnterFrom(originEnterFrom)) return originEnterFrom; + + return fallbackEnterFrom; +}; + +export const NotificationList = ({ + enterFrom = 'bottom', + fallbackPanel, + filter, + panel, + verticalAlignment = 'bottom', +}: NotificationListProps) => { + const { Notification: NotificationComponent = DefaultNotification } = useComponentsContext(); + const { removeNotification, startNotificationTimeout } = useNotificationApi(); + const { + theme: { notificationList }, + } = useTheme(); + const { t } = useTranslationContext(); + const startedTimeoutIdsRef = useRef>(new Set()); + const dragX = useSharedValue(0); + + const combinedFilter = useCallback( + (notification: NotificationType) => { + if (hasSystemNotificationTag(notification)) return false; + return filter ? filter(notification) : true; + }, + [filter], + ); + + const notifications = useNotifications({ + fallbackPanel, + filter: combinedFilter, + panel, + }); + const notification = notifications[0] ?? null; + const notificationEnterFrom = getNotificationEnterFrom(notification, enterFrom); + + const dismiss = useCallback( + (id: string) => { + startedTimeoutIdsRef.current.delete(id); + removeNotification(id); + }, + [removeNotification], + ); + + const panGesture = useMemo( + () => + Gesture.Pan() + .onUpdate((event) => { + dragX.value = event.translationX; + }) + .onEnd((event) => { + const shouldDismiss = + Math.abs(event.translationX) > DISMISS_DISTANCE || + Math.abs(event.velocityX) > DISMISS_VELOCITY; + + if (!shouldDismiss) { + dragX.value = withSpring(0, { damping: 18, stiffness: 180 }); + return; + } + + dragX.value = event.translationX < 0 ? -ENTER_TRANSLATION : ENTER_TRANSLATION; + if (notification) { + runOnJS(dismiss)(notification.id); + } + }), + [dismiss, dragX, notification], + ); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: dragX.value }], + })); + + useEffect(() => { + dragX.value = 0; + }, [dragX, notification?.id]); + + useEffect(() => { + const notificationIds = new Set(notifications.map(({ id }) => id)); + startedTimeoutIdsRef.current.forEach((id) => { + if (!notificationIds.has(id)) { + startedTimeoutIdsRef.current.delete(id); + } + }); + }, [notifications]); + + useEffect(() => { + if (!notification) return; + if (startedTimeoutIdsRef.current.has(notification.id)) return; + + startedTimeoutIdsRef.current.add(notification.id); + startNotificationTimeout(notification.id); + }, [notification, startNotificationTimeout]); + + if (!notification) return null; + + return ( + + + + dismiss(notification.id)} + showClose={!notification.duration} + /> + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + left: primitives.spacingMd, + maxHeight: '100%', + position: 'absolute', + right: primitives.spacingMd, + zIndex: 20, + }, + containerBottom: { + bottom: primitives.spacingMd, + }, + containerTop: { + top: primitives.spacingMd, + }, + notificationWrapper: { + alignSelf: 'stretch', + width: '100%', + }, +}); diff --git a/package/src/components/Notifications/__tests__/NotificationList.test.tsx b/package/src/components/Notifications/__tests__/NotificationList.test.tsx new file mode 100644 index 0000000000..a347ca49e1 --- /dev/null +++ b/package/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -0,0 +1,96 @@ +import React, { PropsWithChildren } from 'react'; + +import { StyleSheet } from 'react-native'; + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { NotificationManager } from 'stream-chat'; + +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { + DEFAULT_USER_LANGUAGE, + TranslationProvider, +} from '../../../contexts/translationContext/TranslationContext'; +import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; +import { NotificationList } from '../NotificationList'; + +const t = ((key: string, options?: Record) => { + if (options?.reason && key.includes('{{reason}}')) { + return key.replace('{{reason}}', String(options.reason)); + } + + return key; +}) as TranslationContextValue['t']; + +const createWrapper = + (manager: NotificationManager) => + ({ children }: PropsWithChildren) => ( + + input ?? new Date(), + userLanguage: DEFAULT_USER_LANGUAGE, + }} + > + {children} + + + ); + +describe('NotificationList', () => { + it('renders client notifications and starts their timeout once displayed', async () => { + const manager = new NotificationManager(); + const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); + + render(, { wrapper: createWrapper(manager) }); + + act(() => { + manager.add({ + message: 'Upload failed', + options: { severity: 'error', tags: ['target:channel'] }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Upload failed')).toBeTruthy()); + expect( + StyleSheet.flatten(screen.getByTestId('notification-list-item').props.style), + ).toMatchObject({ + alignSelf: 'stretch', + width: '100%', + }); + expect(startTimeoutSpy).toHaveBeenCalledWith(manager.notifications[0].id); + }); + + it('does not render system notifications in the snackbar list', () => { + const manager = new NotificationManager(); + manager.add({ + message: 'System notice', + options: { severity: 'info', tags: ['system'] }, + origin: { emitter: 'test' }, + }); + + render(, { wrapper: createWrapper(manager) }); + + expect(screen.queryByTestId('notification-list')).toBeNull(); + }); + + it('dismisses persistent notifications with the close button', async () => { + const manager = new NotificationManager(); + const id = manager.add({ + message: 'Persistent notice', + options: { duration: 0, severity: 'warning', tags: ['target:channel'] }, + origin: { emitter: 'test' }, + }); + + render(, { wrapper: createWrapper(manager) }); + + await waitFor(() => expect(screen.getByText('Persistent notice')).toBeTruthy()); + fireEvent.press(screen.getByTestId('notification-close-button')); + + await waitFor(() => { + expect(manager.notifications.some((notification) => notification.id === id)).toBe(false); + }); + }); +}); diff --git a/package/src/components/Notifications/__tests__/notificationTarget.test.ts b/package/src/components/Notifications/__tests__/notificationTarget.test.ts new file mode 100644 index 0000000000..845581d9c6 --- /dev/null +++ b/package/src/components/Notifications/__tests__/notificationTarget.test.ts @@ -0,0 +1,53 @@ +import type { Notification } from 'stream-chat'; + +import { + addNotificationTargetTag, + getNotificationTargetPanel, + getNotificationTargetPanels, + isNotificationForPanel, +} from '../notificationTarget'; + +const notification = (overrides: Partial): Notification => + ({ + createdAt: Date.now(), + id: 'notification-id', + message: 'Notification', + origin: { emitter: 'test' }, + ...overrides, + }) as Notification; + +describe('notificationTarget', () => { + it('adds a target tag without duplicating existing tags', () => { + expect(addNotificationTargetTag('channel', ['target:channel', 'custom'])).toEqual([ + 'target:channel', + 'custom', + ]); + }); + + it('reads target panels from tags before origin context', () => { + const result = notification({ + origin: { context: { panel: 'thread' }, emitter: 'test' }, + tags: ['target:channel', 'target:thread-list'], + }); + + expect(getNotificationTargetPanel(result)).toBe('channel'); + expect(getNotificationTargetPanels(result)).toEqual(['channel', 'thread-list']); + }); + + it('falls back to origin context panel', () => { + const result = notification({ + origin: { context: { panel: 'thread' }, emitter: 'test' }, + }); + + expect(getNotificationTargetPanel(result)).toBe('thread'); + expect(getNotificationTargetPanels(result)).toEqual(['thread']); + }); + + it('uses channel as the default fallback panel', () => { + const result = notification({}); + + expect(isNotificationForPanel(result, 'channel')).toBe(true); + expect(isNotificationForPanel(result, 'thread')).toBe(false); + expect(isNotificationForPanel(result, 'thread', { fallbackPanel: 'thread' })).toBe(true); + }); +}); diff --git a/package/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx b/package/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx new file mode 100644 index 0000000000..baeb57e31d --- /dev/null +++ b/package/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx @@ -0,0 +1,122 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; + +import { ChannelProvider } from '../../../../contexts/channelContext/ChannelContext'; +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { useNotificationApi } from '../useNotificationApi'; + +const createWrapper = + (client: unknown, channelContext: Record = {}) => + ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + +describe('useNotificationApi', () => { + it('adds inferred target tags and incident-derived types', () => { + const add = jest.fn(); + const client = { + notifications: { + add, + remove: jest.fn(), + startTimeout: jest.fn(), + }, + }; + const { result } = renderHook(() => useNotificationApi(), { + wrapper: createWrapper(client), + }); + + act(() => { + result.current.addNotification({ + emitter: 'MessageComposer', + incident: { domain: 'api', entity: 'message', operation: 'send' }, + message: 'Could not send message', + severity: 'error', + }); + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Could not send message', + options: { + severity: 'error', + tags: ['target:channel'], + type: 'api:message:send:failed', + }, + origin: { emitter: 'MessageComposer' }, + }); + }); + + it('uses explicit target panels instead of inferred panel', () => { + const add = jest.fn(); + const client = { + notifications: { + add, + remove: jest.fn(), + startTimeout: jest.fn(), + }, + }; + const { result } = renderHook(() => useNotificationApi(), { + wrapper: createWrapper(client), + }); + + act(() => { + result.current.addNotification({ + emitter: 'Poll', + message: 'Poll ended', + severity: 'success', + targetPanels: ['channel', 'thread'], + }); + }); + + expect(add).toHaveBeenCalledWith({ + message: 'Poll ended', + options: { + severity: 'success', + tags: ['target:channel', 'target:thread'], + }, + origin: { emitter: 'Poll' }, + }); + }); + + it('adds system notifications with the system tag', () => { + const add = jest.fn(() => 'notification-id'); + const client = { + notifications: { + add, + remove: jest.fn(), + startTimeout: jest.fn(), + }, + }; + const { result } = renderHook(() => useNotificationApi(), { + wrapper: createWrapper(client, { threadList: true }), + }); + + const id = result.current.addSystemNotification({ + emitter: 'Connection', + message: 'Reconnecting', + severity: 'warning', + tags: ['network'], + }); + + expect(id).toBe('notification-id'); + expect(add).toHaveBeenCalledWith({ + message: 'Reconnecting', + options: { + severity: 'warning', + tags: ['system', 'network'], + }, + origin: { emitter: 'Connection' }, + }); + }); +}); diff --git a/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx b/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx new file mode 100644 index 0000000000..54edb0f3d3 --- /dev/null +++ b/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx @@ -0,0 +1,85 @@ +import React, { PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; + +import { ChannelProvider } from '../../../../contexts/channelContext/ChannelContext'; +import { ChannelsProvider } from '../../../../contexts/channelsContext/ChannelsContext'; +import { ThreadProvider } from '../../../../contexts/threadContext/ThreadContext'; +import { ThreadsProvider } from '../../../../contexts/threadsContext/ThreadsContext'; +import { useNotificationTarget } from '../useNotificationTarget'; + +const channelContext = { channel: { cid: 'messaging:channel' } } as never; +const channelsContext = { channels: [] } as never; +const threadsContext = { threads: [] } as never; + +describe('useNotificationTarget', () => { + it('returns undefined outside notification target providers', () => { + const { result } = renderHook(() => useNotificationTarget()); + + expect(result.current).toBeUndefined(); + }); + + it('targets the channel when rendered inside Channel', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const { result } = renderHook(() => useNotificationTarget(), { wrapper }); + + expect(result.current).toBe('channel'); + }); + + it('prioritizes channel over surrounding list providers', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useNotificationTarget(), { wrapper }); + + expect(result.current).toBe('channel'); + }); + + it('targets thread when Channel is in thread-list mode', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useNotificationTarget(), { wrapper }); + + expect(result.current).toBe('thread'); + }); + + it('prioritizes thread over channel', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook(() => useNotificationTarget(), { wrapper }); + + expect(result.current).toBe('thread'); + }); + + it('targets list providers when no Channel provider is present', () => { + const channelListWrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const threadListWrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + expect( + renderHook(() => useNotificationTarget(), { wrapper: channelListWrapper }).result.current, + ).toBe('channel-list'); + expect( + renderHook(() => useNotificationTarget(), { wrapper: threadListWrapper }).result.current, + ).toBe('thread-list'); + }); +}); diff --git a/package/src/components/Notifications/hooks/index.ts b/package/src/components/Notifications/hooks/index.ts new file mode 100644 index 0000000000..d698180b49 --- /dev/null +++ b/package/src/components/Notifications/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './useNotificationApi'; +export * from './useNotifications'; +export * from './useNotificationTarget'; +export * from './useSystemNotifications'; diff --git a/package/src/components/Notifications/hooks/useNotificationApi.ts b/package/src/components/Notifications/hooks/useNotificationApi.ts new file mode 100644 index 0000000000..d62bc616c6 --- /dev/null +++ b/package/src/components/Notifications/hooks/useNotificationApi.ts @@ -0,0 +1,179 @@ +import { useCallback } from 'react'; + +import type { Notification, NotificationAction, NotificationSeverity } from 'stream-chat'; + +import { useNotificationTarget } from './useNotificationTarget'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { + addNotificationTargetTag, + getNotificationTargetTag, + type NotificationTargetPanel, +} from '../notificationTarget'; + +export const SYSTEM_NOTIFICATION_TAG = 'system' as const; + +export const hasSystemNotificationTag = (notification: Notification) => + notification.tags?.includes(SYSTEM_NOTIFICATION_TAG) ?? false; + +export type NotificationIncidentDescriptor = { + domain: string; + entity: string; + operation: string; + status?: string; +}; + +export type AddNotificationParams = { + actions?: NotificationAction[]; + context?: Record; + duration?: number; + emitter: string; + error?: Error; + incident?: NotificationIncidentDescriptor; + message: string; + metadata?: Record; + severity?: NotificationSeverity; + tags?: string[]; + targetPanels?: NotificationTargetPanel[]; + type?: string; +}; + +export type AddSystemNotificationParams = Omit; + +export type AddNotification = (params: AddNotificationParams) => void; +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, + metadata, + severity, + tags, + targetPanels, + type, + }: AddNotificationParams) => { + const notificationTags = getTargetTags(targetPanels, inferredPanel, tags); + const resolvedType = getTypeFromIncident({ incident, severity, type }); + const origin = context ? { context, emitter } : { emitter }; + + client.notifications.add({ + message, + options: { + ...(actions ? { actions } : {}), + ...(typeof duration === 'number' ? { duration } : {}), + ...(error ? { originalError: error } : {}), + ...(metadata ? { metadata } : {}), + ...(notificationTags.length > 0 ? { tags: notificationTags } : {}), + ...(severity ? { severity } : {}), + ...(resolvedType ? { type: resolvedType } : {}), + }, + origin, + }); + }, + [client, inferredPanel], + ); + + const addSystemNotification: AddSystemNotification = useCallback( + ({ + actions, + context, + duration, + emitter, + error, + incident, + message, + metadata, + 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 }; + + return client.notifications.add({ + message, + options: { + ...(actions ? { actions } : {}), + ...(typeof duration === 'number' ? { duration } : {}), + ...(error ? { originalError: error } : {}), + ...(metadata ? { metadata } : {}), + ...(notificationTags.length > 0 ? { tags: notificationTags } : {}), + ...(severity ? { severity } : {}), + ...(resolvedType ? { type: resolvedType } : {}), + }, + 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/package/src/components/Notifications/hooks/useNotificationTarget.ts b/package/src/components/Notifications/hooks/useNotificationTarget.ts new file mode 100644 index 0000000000..0e87d193d7 --- /dev/null +++ b/package/src/components/Notifications/hooks/useNotificationTarget.ts @@ -0,0 +1,37 @@ +import { useContext } from 'react'; + +import { + ChannelContext, + type ChannelContextValue, +} from '../../../contexts/channelContext/ChannelContext'; +import { + ChannelsContext, + type ChannelsContextValue, +} from '../../../contexts/channelsContext/ChannelsContext'; +import { + ThreadContext, + type ThreadContextValue, +} from '../../../contexts/threadContext/ThreadContext'; +import { + ThreadsContext, + type ThreadsContextValue, +} from '../../../contexts/threadsContext/ThreadsContext'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../../../contexts/utils/defaultBaseContextValue'; +import type { NotificationTargetPanel } from '../notificationTarget'; + +const isProvided = (value: T) => value !== (DEFAULT_BASE_CONTEXT_VALUE as T); + +export const useNotificationTarget = (): NotificationTargetPanel | undefined => { + const channelContext = useContext(ChannelContext) as ChannelContextValue; + const channelsContext = useContext(ChannelsContext) as ChannelsContextValue; + const threadContext = useContext(ThreadContext) as ThreadContextValue; + const threadsContext = useContext(ThreadsContext) as ThreadsContextValue; + + if (isProvided(channelContext) && channelContext.threadList) return 'thread'; + if (isProvided(threadContext) && threadContext.threadInstance) return 'thread'; + if (isProvided(channelContext) && channelContext.channel) return 'channel'; + if (isProvided(threadsContext)) return 'thread-list'; + if (isProvided(channelsContext)) return 'channel-list'; + + return undefined; +}; diff --git a/package/src/components/Notifications/hooks/useNotifications.ts b/package/src/components/Notifications/hooks/useNotifications.ts new file mode 100644 index 0000000000..1a963196b5 --- /dev/null +++ b/package/src/components/Notifications/hooks/useNotifications.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; + +import type { Notification, NotificationManagerState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { isNotificationForPanel, type NotificationTargetPanel } from '../notificationTarget'; + +export type UseNotificationsFilter = (notification: Notification) => boolean; + +export type UseNotificationsOptions = { + filter?: UseNotificationsFilter; + panel?: NotificationTargetPanel; + fallbackPanel?: NotificationTargetPanel; +}; + +export const useNotifications = (options?: UseNotificationsOptions): Notification[] => { + const { client } = useChatContext(); + const selector = useCallback( + (state: NotificationManagerState) => { + const notifications = state.notifications; + const panel = options?.panel; + const byPanel = panel + ? notifications.filter((notification) => + isNotificationForPanel(notification, panel, { + fallbackPanel: options?.fallbackPanel, + }), + ) + : notifications; + + return { + notifications: options?.filter ? byPanel.filter(options.filter) : byPanel, + }; + }, + [options?.fallbackPanel, options?.filter, options?.panel], + ); + + const { notifications } = useStateStore(client.notifications.store, selector); + + return notifications; +}; diff --git a/package/src/components/Notifications/hooks/useSystemNotifications.ts b/package/src/components/Notifications/hooks/useSystemNotifications.ts new file mode 100644 index 0000000000..0e8cb8164a --- /dev/null +++ b/package/src/components/Notifications/hooks/useSystemNotifications.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import type { Notification, NotificationManagerState } from 'stream-chat'; + +import { hasSystemNotificationTag } from './useNotificationApi'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +export type UseSystemNotificationsFilter = (notification: Notification) => boolean; + +export type UseSystemNotificationsOptions = { + filter?: UseSystemNotificationsFilter; +}; + +export const useSystemNotifications = (options?: UseSystemNotificationsOptions): Notification[] => { + const { client } = useChatContext(); + const selector = useCallback( + (state: NotificationManagerState) => { + const withSystemTag = state.notifications.filter(hasSystemNotificationTag); + return { + notifications: options?.filter ? withSystemTag.filter(options.filter) : withSystemTag, + }; + }, + [options?.filter], + ); + + const { notifications } = useStateStore(client.notifications.store, selector); + + return notifications; +}; diff --git a/package/src/components/Notifications/index.ts b/package/src/components/Notifications/index.ts new file mode 100644 index 0000000000..35de8e01d0 --- /dev/null +++ b/package/src/components/Notifications/index.ts @@ -0,0 +1,4 @@ +export * from './Notification'; +export * from './NotificationList'; +export * from './hooks'; +export * from './notificationTarget'; diff --git a/package/src/components/Notifications/notificationTarget.ts b/package/src/components/Notifications/notificationTarget.ts new file mode 100644 index 0000000000..838e6c5cd6 --- /dev/null +++ b/package/src/components/Notifications/notificationTarget.ts @@ -0,0 +1,63 @@ +import type { Notification } from 'stream-chat'; + +const NOTIFICATION_TARGET_PANELS = ['channel', 'thread', 'channel-list', 'thread-list'] as const; + +export type NotificationTargetPanel = (typeof NOTIFICATION_TARGET_PANELS)[number]; + +export const isNotificationTargetPanel = (value: unknown): value is NotificationTargetPanel => + typeof value === 'string' && (NOTIFICATION_TARGET_PANELS as readonly string[]).includes(value); + +export const getNotificationTargetTag = (panel: NotificationTargetPanel) => + `target:${panel}` as const; + +export const addNotificationTargetTag = ( + panel: NotificationTargetPanel | undefined, + tags?: string[], +) => { + if (!panel) return tags ?? []; + + return Array.from(new Set([getNotificationTargetTag(panel), ...(tags ?? [])])); +}; + +export const getNotificationTargetPanel = ( + notification: Notification, +): NotificationTargetPanel | undefined => { + const targetTag = notification.tags?.find((tag) => tag.startsWith('target:')); + if (targetTag) { + const candidate = targetTag.slice('target:'.length); + if (isNotificationTargetPanel(candidate)) return candidate; + } + + const panel = notification.origin.context?.panel; + 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 isNotificationForPanel = ( + notification: Notification, + panel: NotificationTargetPanel, + options?: { fallbackPanel?: NotificationTargetPanel }, +) => { + const explicitTargetPanels = getNotificationTargetPanels(notification); + if (explicitTargetPanels.length > 0) { + return explicitTargetPanels.includes(panel); + } + + const resolvedPanel = options?.fallbackPanel ?? 'channel'; + return resolvedPanel === panel; +}; diff --git a/package/src/components/Notifications/notificationTranslations.ts b/package/src/components/Notifications/notificationTranslations.ts new file mode 100644 index 0000000000..6e70af7ec9 --- /dev/null +++ b/package/src/components/Notifications/notificationTranslations.ts @@ -0,0 +1,142 @@ +import type { TFunction } from 'i18next'; +import type { Notification } from 'stream-chat'; + +const normalizeReason = (notification?: Notification) => { + const reason = notification?.metadata?.reason; + if (typeof reason !== 'string' || !reason.length) return undefined; + + return reason.toLowerCase(); +}; + +const withReasonFallback = ({ + fallbackTranslationKey, + notification, + reasonTranslationKey, + t, +}: { + fallbackTranslationKey: string; + notification?: Notification; + reasonTranslationKey: string; + t: TFunction; +}) => { + const reason = normalizeReason(notification); + if (!reason) return t(fallbackTranslationKey); + + return t(reasonTranslationKey, { reason }); +}; + +const translateAttachmentUploadBlocked = ({ + notification, + t, +}: { + notification?: Notification; + t: TFunction; +}) => { + const rawReason = notification?.metadata?.reason; + let reason = t('unsupported file type'); + if (typeof rawReason !== 'string') reason = t('unknown error'); + if (rawReason === 'size_limit') reason = t('size limit'); + + return t('Attachment upload blocked due to {{reason}}', { reason }); +}; + +const translateAttachmentUploadFailed = ({ + notification, + t, +}: { + notification?: Notification; + t: TFunction; +}) => + withReasonFallback({ + fallbackTranslationKey: 'Error uploading attachment', + notification, + reasonTranslationKey: 'Attachment upload failed due to {{reason}}', + t, + }); + +const translatePollCreateFailed = ({ + notification, + t, +}: { + notification?: Notification; + t: TFunction; +}) => + withReasonFallback({ + fallbackTranslationKey: 'Failed to create the poll', + notification, + reasonTranslationKey: 'Failed to create the poll due to {{reason}}', + t, + }); + +const translatePollEndFailed = ({ + notification, + t, +}: { + notification?: Notification; + t: TFunction; +}) => + withReasonFallback({ + fallbackTranslationKey: 'Failed to end the poll', + notification, + reasonTranslationKey: 'Failed to end the poll due to {{reason}}', + t, + }); + +const translateCommandDisabled = ({ + notification, + t, +}: { + notification?: Notification; + t: TFunction; +}) => { + const reason = normalizeReason(notification); + + if (reason === 'editing') { + return t('Command not available while editing'); + } + + if (reason === 'quoted_message') { + return t('Command not available while replying'); + } + + return t(notification?.message || 'Command not available'); +}; + +const notificationTranslatorsByType: Record< + string, + (options: { notification: Notification; t: TFunction }) => string +> = { + 'api:attachment:upload:failed': translateAttachmentUploadFailed, + 'api:location:create:failed': ({ t }) => t('Failed to share location'), + 'api:location:share:failed': ({ t }) => t('Failed to share location'), + 'api:poll:create:failed': translatePollCreateFailed, + 'api:poll:end:failed': translatePollEndFailed, + 'api:poll:end:success': ({ t }) => t('Poll ended'), + 'api:reply:search:failed': ({ t }) => t('Thread has not been found'), + 'browser:audio:playback:error': ({ notification, t }) => + notification.message ? t(notification.message) : t('Error reproducing the recording'), + 'browser:location:get:failed': ({ t }) => t('Failed to retrieve location'), + 'channel:jumpToFirstUnread:failed': ({ t }) => t('Failed to jump to the first unread message'), + 'validation:attachment:file:missing': ({ t }) => t('File is required for upload attachment'), + 'validation:attachment:id:missing': ({ t }) => t('Local upload attachment missing local id'), + 'validation:attachment:upload:blocked': translateAttachmentUploadBlocked, + 'validation:attachment:upload:in-progress': ({ t }) => + t('Wait until all attachments have uploaded'), + 'validation:command:disabled': translateCommandDisabled, + 'validation:poll:castVote:limit': ({ t }) => + t('Reached the vote limit. Remove an existing vote first.'), +}; + +export const getNotificationDisplayMessage = ({ + notification, + t, +}: { + notification: Notification; + t: TFunction; +}) => { + const translator = notification.type + ? notificationTranslatorsByType[notification.type] + : undefined; + + return translator ? translator({ notification, t }) : t(notification.message); +}; diff --git a/package/src/components/ThreadList/ThreadList.tsx b/package/src/components/ThreadList/ThreadList.tsx index 5d2cbb031e..a77735a77a 100644 --- a/package/src/components/ThreadList/ThreadList.tsx +++ b/package/src/components/ThreadList/ThreadList.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { FlatList, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; import { Thread, ThreadManagerState } from 'stream-chat'; @@ -77,7 +77,7 @@ export const DefaultThreadListComponent = () => { export const ThreadList = (props: ThreadListProps) => { const { isFocused = true } = props; - const { ThreadListComponent: ThreadListContent } = useComponentsContext(); + const { NotificationList, ThreadListComponent: ThreadListContent } = useComponentsContext(); const { client } = useChatContext(); useEffect(() => { @@ -112,7 +112,16 @@ export const ThreadList = (props: ThreadListProps) => { - + + + + ); }; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index ea33fbb759..5896fcc674 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -175,6 +175,8 @@ export * from './MessageMenu/MessageReactionPicker'; export * from './MessageMenu/utils/toUnicodeScalarString'; export * from './MessageMenu/hooks/useFetchReactions'; +export * from './Notifications'; + export * from './ProgressControl/ProgressControl'; export * from './ProgressControl/WaveProgressBar'; export * from './Poll'; diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index ca2460a841..6735fba586 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -130,6 +130,7 @@ import { MessageReactionPicker } from '../../components/MessageMenu/MessageReact import { MessageUserReactions } from '../../components/MessageMenu/MessageUserReactions'; import { MessageUserReactionsAvatar } from '../../components/MessageMenu/MessageUserReactionsAvatar'; import { MessageUserReactionsItem } from '../../components/MessageMenu/MessageUserReactionsItem'; +import { Notification, NotificationIcon, NotificationList } from '../../components/Notifications'; import { PollAnswersListContent } from '../../components/Poll/components/PollAnswersList'; import { PollButtons } from '../../components/Poll/components/PollButtons'; import { PollAllOptionsContent } from '../../components/Poll/components/PollOption'; @@ -245,6 +246,9 @@ const components = { MessageUserReactionsAvatar, MessageUserReactionsItem, NetworkDownIndicator, + Notification, + NotificationIcon, + NotificationList, ChannelPreview: ChannelPreviewView, ChannelPreviewAvatar: ChannelAvatar, ChannelPreviewLastMessage: ChannelLastMessagePreview, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 8eee45fdf3..bf2bee724e 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -532,6 +532,19 @@ export type Theme = { rightButtonContainer: ViewStyle; }; }; + notification: { + actionButton: ViewStyle; + actionButtonText: TextStyle; + actionsContainer: ViewStyle; + closeButton: ViewStyle; + container: ViewStyle; + contentContainer: ViewStyle; + iconContainer: ViewStyle; + message: TextStyle; + }; + notificationList: { + container: ViewStyle; + }; messageMenu: { actionList: { container: ViewStyle; @@ -1457,6 +1470,19 @@ export const defaultTheme: Theme = { }, unreadMessagesNotificationContainer: {}, }, + notification: { + actionButton: {}, + actionButtonText: {}, + actionsContainer: {}, + closeButton: {}, + container: {}, + contentContainer: {}, + iconContainer: {}, + message: {}, + }, + notificationList: { + container: {}, + }, messageMenu: { actionList: { container: {}, diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 91c9dde3eb..ef6548c93d 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -12,6 +12,7 @@ export * from './useQueryReminders'; export * from './useAfterKeyboardOpenCallback'; export * from './useClientNotifications'; export * from './useInAppNotificationsState'; +export * from '../components/Notifications/hooks'; export * from './usePortalSettledCallback'; export * from './useRAFCoalescedValue'; export * from './useAudioPlayer'; diff --git a/package/src/hooks/useInAppNotificationsState.ts b/package/src/hooks/useInAppNotificationsState.ts index f9f275bcce..e4c8b0f636 100644 --- a/package/src/hooks/useInAppNotificationsState.ts +++ b/package/src/hooks/useInAppNotificationsState.ts @@ -14,6 +14,12 @@ const selector = ({ notifications }: InAppNotificationsState) => ({ notifications, }); +/** + * @deprecated Prefer the client-backed notification APIs exported from + * `components/Notifications` (`useNotificationApi`, `useNotifications`, and + * `NotificationList`). This hook is kept for apps that already render their own + * legacy in-app notification store. + */ export const useInAppNotificationsState = () => { const { notifications } = useStateStore(inAppNotificationsStore, selector); diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index b824a9d92c..1203f536e3 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Notifications", + "a11y/Dismiss notification": "Dismiss notification", + "Attachment upload blocked due to {{reason}}": "Attachment upload blocked due to {{reason}}", + "Attachment upload failed due to {{reason}}": "Attachment upload failed due to {{reason}}", + "Command not available": "Command not available", + "Command not available while editing": "Command not available while editing", + "Command not available while replying": "Command not available while replying", + "Error reproducing the recording": "Error reproducing the recording", + "Error uploading attachment": "Error uploading attachment", + "Failed to create the poll": "Failed to create the poll", + "Failed to create the poll due to {{reason}}": "Failed to create the poll due to {{reason}}", + "Failed to end the poll": "Failed to end the poll", + "Failed to end the poll due to {{reason}}": "Failed to end the poll due to {{reason}}", + "Failed to jump to the first unread message": "Failed to jump to the first unread message", + "Failed to retrieve location": "Failed to retrieve location", + "Failed to share location": "Failed to share location", + "File is required for upload attachment": "File is required for upload attachment", + "Local upload attachment missing local id": "Local upload attachment missing local id", + "Poll ended": "Poll ended", + "Reached the vote limit. Remove an existing vote first.": "Reached the vote limit. Remove an existing vote first.", + "Thread has not been found": "Thread has not been found", + "Wait until all attachments have uploaded": "Wait until all attachments have uploaded", + "size limit": "size limit", + "unknown error": "unknown error", + "unsupported file type": "unsupported file type" } From 3dfc02c2e3f4cdb31808b4385577af34fc5cd1b0 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 12:17:05 +0200 Subject: [PATCH 2/8] fix: disable sequencing --- .../Notifications/NotificationList.tsx | 35 ++++++- .../__tests__/NotificationList.test.tsx | 98 ++++++++++++++++++- 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/package/src/components/Notifications/NotificationList.tsx b/package/src/components/Notifications/NotificationList.tsx index a4904ccde0..d6b8e0a624 100644 --- a/package/src/components/Notifications/NotificationList.tsx +++ b/package/src/components/Notifications/NotificationList.tsx @@ -77,6 +77,23 @@ const getNotificationEnterFrom = ( return fallbackEnterFrom; }; +const getStringValue = (value: unknown) => (typeof value === 'string' ? value : undefined); + +const getNotificationPresentationKey = (notification: NotificationType) => + notification.type ?? + getStringValue(notification.metadata?.dedupeKey) ?? + getStringValue(notification.origin.context?.dedupeKey) ?? + [notification.origin.emitter, notification.severity, notification.message] + .filter(Boolean) + .join(':'); + +const getNewestNotification = (notifications: NotificationType[]) => + notifications.reduce( + (newest, notification) => + !newest || notification.createdAt >= newest.createdAt ? notification : newest, + null, + ); + export const NotificationList = ({ enterFrom = 'bottom', fallbackPanel, @@ -106,7 +123,10 @@ export const NotificationList = ({ filter: combinedFilter, panel, }); - const notification = notifications[0] ?? null; + const notification = getNewestNotification(notifications); + const notificationPresentationKey = notification + ? getNotificationPresentationKey(notification) + : undefined; const notificationEnterFrom = getNotificationEnterFrom(notification, enterFrom); const dismiss = useCallback( @@ -158,6 +178,17 @@ export const NotificationList = ({ }); }, [notifications]); + useEffect(() => { + if (!notification || notifications.length <= 1) return; + + notifications.forEach(({ id }) => { + if (id === notification.id) return; + + startedTimeoutIdsRef.current.delete(id); + removeNotification(id); + }); + }, [notification, notifications, removeNotification]); + useEffect(() => { if (!notification) return; if (startedTimeoutIdsRef.current.has(notification.id)) return; @@ -183,7 +214,7 @@ export const NotificationList = ({ diff --git a/package/src/components/Notifications/__tests__/NotificationList.test.tsx b/package/src/components/Notifications/__tests__/NotificationList.test.tsx index a347ca49e1..c738a6f810 100644 --- a/package/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/package/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -1,11 +1,15 @@ import React, { PropsWithChildren } from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, Text } from 'react-native'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { NotificationManager } from 'stream-chat'; import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; +import { + ComponentOverrides, + WithComponents, +} from '../../../contexts/componentsContext/ComponentsContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import { DEFAULT_USER_LANGUAGE, @@ -23,7 +27,7 @@ const t = ((key: string, options?: Record) => { }) as TranslationContextValue['t']; const createWrapper = - (manager: NotificationManager) => + (manager: NotificationManager, overrides?: ComponentOverrides) => ({ children }: PropsWithChildren) => ( - {children} + + {overrides ? {children} : children} + ); @@ -93,4 +99,90 @@ describe('NotificationList', () => { expect(manager.notifications.some((notification) => notification.id === id)).toBe(false); }); }); + + it('shows the newest notification and removes older matching notifications instead of queueing', async () => { + const manager = new NotificationManager(); + const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); + let firstId = ''; + let threadId = ''; + let secondId = ''; + + render(, { wrapper: createWrapper(manager) }); + + act(() => { + firstId = manager.add({ + message: 'First notice', + options: { severity: 'info', tags: ['target:channel'], type: 'ui:first' }, + origin: { emitter: 'test' }, + }); + threadId = manager.add({ + message: 'Thread notice', + options: { severity: 'info', tags: ['target:thread'], type: 'ui:thread' }, + origin: { emitter: 'test' }, + }); + secondId = manager.add({ + message: 'Second notice', + options: { severity: 'warning', tags: ['target:channel'], type: 'ui:second' }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Second notice')).toBeTruthy()); + expect(screen.queryByText('First notice')).toBeNull(); + + await waitFor(() => { + expect(manager.notifications.some((notification) => notification.id === firstId)).toBe(false); + expect(manager.notifications.some((notification) => notification.id === threadId)).toBe(true); + expect(manager.notifications.some((notification) => notification.id === secondId)).toBe(true); + }); + expect(startTimeoutSpy).toHaveBeenCalledWith(secondId); + }); + + it('updates repeated matching notifications without remounting the snackbar item', async () => { + const manager = new NotificationManager(); + const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); + let mountCount = 0; + let firstId = ''; + let secondId = ''; + + const Notification: ComponentOverrides['Notification'] = ({ notification }) => { + React.useEffect(() => { + mountCount += 1; + }, []); + + return {notification.message}; + }; + + render(, { + wrapper: createWrapper(manager, { Notification }), + }); + + act(() => { + firstId = manager.add({ + message: 'Copied', + options: { severity: 'success', tags: ['target:channel'], type: 'ui:copy' }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Copied')).toBeTruthy()); + expect(mountCount).toBe(1); + + act(() => { + secondId = manager.add({ + message: 'Copied again', + options: { severity: 'success', tags: ['target:channel'], type: 'ui:copy' }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Copied again')).toBeTruthy()); + await waitFor(() => { + expect(manager.notifications.some((notification) => notification.id === firstId)).toBe(false); + expect(manager.notifications.some((notification) => notification.id === secondId)).toBe(true); + }); + expect(mountCount).toBe(1); + expect(startTimeoutSpy).toHaveBeenCalledWith(firstId); + expect(startTimeoutSpy).toHaveBeenCalledWith(secondId); + }); }); From 6f92bc7b3f2ec1c26a78740cccf749c2071e9fab Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 13:05:35 +0200 Subject: [PATCH 3/8] feat: persistent notifications --- .../Notifications/NotificationList.tsx | 26 ++++- .../__tests__/NotificationList.test.tsx | 105 ++++++++++++++++++ .../Notifications/hooks/useNotificationApi.ts | 9 +- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/package/src/components/Notifications/NotificationList.tsx b/package/src/components/Notifications/NotificationList.tsx index d6b8e0a624..a271465bd8 100644 --- a/package/src/components/Notifications/NotificationList.tsx +++ b/package/src/components/Notifications/NotificationList.tsx @@ -44,6 +44,7 @@ export type NotificationListProps = { const ENTER_TRANSLATION = 96; const DISMISS_DISTANCE = 80; const DISMISS_VELOCITY = 800; +const ACTION_NOTIFICATION_DURATION = 5000; const enteringAnimations = { bottom: SlideInDown.duration(180), @@ -94,6 +95,22 @@ const getNewestNotification = (notifications: NotificationType[]) => null, ); +const isPersistentNotification = (notification: NotificationType) => !notification.duration; + +const getActiveNotification = (notifications: NotificationType[]) => { + const persistentNotifications = notifications.filter(isPersistentNotification); + return getNewestNotification( + persistentNotifications.length > 0 ? persistentNotifications : notifications, + ); +}; + +const getNotificationDurationOverride = (notification: NotificationType) => { + if (isPersistentNotification(notification)) return undefined; + if (!notification.actions?.length) return undefined; + + return Math.max(notification.duration ?? 0, ACTION_NOTIFICATION_DURATION); +}; + export const NotificationList = ({ enterFrom = 'bottom', fallbackPanel, @@ -123,7 +140,7 @@ export const NotificationList = ({ filter: combinedFilter, panel, }); - const notification = getNewestNotification(notifications); + const notification = getActiveNotification(notifications); const notificationPresentationKey = notification ? getNotificationPresentationKey(notification) : undefined; @@ -194,7 +211,12 @@ export const NotificationList = ({ if (startedTimeoutIdsRef.current.has(notification.id)) return; startedTimeoutIdsRef.current.add(notification.id); - startNotificationTimeout(notification.id); + const durationOverride = getNotificationDurationOverride(notification); + if (typeof durationOverride === 'number') { + startNotificationTimeout(notification.id, durationOverride); + } else { + startNotificationTimeout(notification.id); + } }, [notification, startNotificationTimeout]); if (!notification) return null; diff --git a/package/src/components/Notifications/__tests__/NotificationList.test.tsx b/package/src/components/Notifications/__tests__/NotificationList.test.tsx index c738a6f810..b029809085 100644 --- a/package/src/components/Notifications/__tests__/NotificationList.test.tsx +++ b/package/src/components/Notifications/__tests__/NotificationList.test.tsx @@ -138,6 +138,111 @@ describe('NotificationList', () => { expect(startTimeoutSpy).toHaveBeenCalledWith(secondId); }); + it('keeps a persistent notification visible when a transient notification arrives', async () => { + const manager = new NotificationManager(); + const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); + let persistentId = ''; + let transientId = ''; + + render(, { wrapper: createWrapper(manager) }); + + act(() => { + persistentId = manager.add({ + message: 'Retry upload', + options: { + actions: [{ handler: jest.fn(), label: 'Retry' }], + duration: 0, + severity: 'error', + tags: ['target:channel'], + type: 'ui:upload:retry', + }, + origin: { emitter: 'test' }, + }); + transientId = manager.add({ + message: 'Copied', + options: { severity: 'success', tags: ['target:channel'], type: 'ui:copy' }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Retry upload')).toBeTruthy()); + expect(screen.queryByText('Copied')).toBeNull(); + + await waitFor(() => { + expect(manager.notifications.some((notification) => notification.id === persistentId)).toBe( + true, + ); + expect(manager.notifications.some((notification) => notification.id === transientId)).toBe( + false, + ); + }); + expect(startTimeoutSpy).toHaveBeenCalledWith(persistentId); + expect(startTimeoutSpy).not.toHaveBeenCalledWith(transientId); + }); + + it('lets a persistent notification replace a transient notification', async () => { + const manager = new NotificationManager(); + let transientId = ''; + let persistentId = ''; + + render(, { wrapper: createWrapper(manager) }); + + act(() => { + transientId = manager.add({ + message: 'Copied', + options: { severity: 'success', tags: ['target:channel'], type: 'ui:copy' }, + origin: { emitter: 'test' }, + }); + persistentId = manager.add({ + message: 'Retry upload', + options: { + actions: [{ handler: jest.fn(), label: 'Retry' }], + duration: 0, + severity: 'error', + tags: ['target:channel'], + type: 'ui:upload:retry', + }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Retry upload')).toBeTruthy()); + expect(screen.queryByText('Copied')).toBeNull(); + + await waitFor(() => { + expect(manager.notifications.some((notification) => notification.id === transientId)).toBe( + false, + ); + expect(manager.notifications.some((notification) => notification.id === persistentId)).toBe( + true, + ); + }); + }); + + it('starts action notification timeouts with a longer LLC duration override', async () => { + const manager = new NotificationManager(); + const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); + let id = ''; + + render(, { wrapper: createWrapper(manager) }); + + act(() => { + id = manager.add({ + message: 'Undo delete', + options: { + actions: [{ handler: jest.fn(), label: 'Undo' }], + severity: 'info', + tags: ['target:channel'], + type: 'ui:message:delete:undo', + }, + origin: { emitter: 'test' }, + }); + }); + + await waitFor(() => expect(screen.getByText('Undo delete')).toBeTruthy()); + expect(startTimeoutSpy).toHaveBeenCalledWith(id, 5000); + }); + it('updates repeated matching notifications without remounting the snackbar item', async () => { const manager = new NotificationManager(); const startTimeoutSpy = jest.spyOn(manager, 'startTimeout').mockImplementation(); diff --git a/package/src/components/Notifications/hooks/useNotificationApi.ts b/package/src/components/Notifications/hooks/useNotificationApi.ts index d62bc616c6..84d1edd101 100644 --- a/package/src/components/Notifications/hooks/useNotificationApi.ts +++ b/package/src/components/Notifications/hooks/useNotificationApi.ts @@ -43,7 +43,7 @@ export type AddSystemNotificationParams = Omit void; export type AddSystemNotification = (params: AddSystemNotificationParams) => string; export type RemoveNotification = (id: string) => void; -export type StartNotificationTimeout = (id: string) => void; +export type StartNotificationTimeout = (id: string, durationOverride?: number) => void; export type NotificationApi = { addNotification: AddNotification; @@ -164,7 +164,12 @@ export const useNotificationApi = (): NotificationApi => { ); const startNotificationTimeout: StartNotificationTimeout = useCallback( - (id) => { + (id, durationOverride) => { + if (typeof durationOverride === 'number') { + client.notifications.startTimeout(id, durationOverride); + return; + } + client.notifications.startTimeout(id); }, [client], From c51f7b436d639f23434738b0e3119ed822226fa3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 14:14:32 +0200 Subject: [PATCH 4/8] chore: bump stream-chat-js and translations --- package/package.json | 2 +- package/src/i18n/es.json | 27 ++++++++++++++++++++++++++- package/src/i18n/fr.json | 27 ++++++++++++++++++++++++++- package/src/i18n/he.json | 27 ++++++++++++++++++++++++++- package/src/i18n/hi.json | 27 ++++++++++++++++++++++++++- package/src/i18n/it.json | 27 ++++++++++++++++++++++++++- package/src/i18n/ja.json | 27 ++++++++++++++++++++++++++- package/src/i18n/ko.json | 27 ++++++++++++++++++++++++++- package/src/i18n/nl.json | 27 ++++++++++++++++++++++++++- package/src/i18n/pt-br.json | 27 ++++++++++++++++++++++++++- package/src/i18n/ru.json | 27 ++++++++++++++++++++++++++- package/src/i18n/tr.json | 27 ++++++++++++++++++++++++++- package/yarn.lock | 8 ++++---- 13 files changed, 291 insertions(+), 16 deletions(-) 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/i18n/es.json b/package/src/i18n/es.json index a08d8c2792..191f08dd51 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Notificaciones", + "a11y/Dismiss notification": "Descartar notificación", + "Attachment upload blocked due to {{reason}}": "Carga de adjunto bloqueada por {{reason}}", + "Attachment upload failed due to {{reason}}": "Error al cargar el adjunto debido a {{reason}}", + "Command not available": "Comando no disponible", + "Command not available while editing": "Comando no disponible mientras editas", + "Command not available while replying": "Comando no disponible mientras respondes", + "Error reproducing the recording": "Error al reproducir la grabación", + "Error uploading attachment": "Error al cargar el adjunto", + "Failed to create the poll": "No se pudo crear la encuesta", + "Failed to create the poll due to {{reason}}": "No se pudo crear la encuesta debido a {{reason}}", + "Failed to end the poll": "No se pudo finalizar la encuesta", + "Failed to end the poll due to {{reason}}": "No se pudo finalizar la encuesta debido a {{reason}}", + "Failed to jump to the first unread message": "No se pudo ir al primer mensaje no leído", + "Failed to retrieve location": "No se pudo obtener la ubicación", + "Failed to share location": "No se pudo compartir la ubicación", + "File is required for upload attachment": "Se requiere un archivo para cargar un adjunto", + "Local upload attachment missing local id": "Falta el ID local del adjunto local de carga", + "Poll ended": "Encuesta finalizada", + "Reached the vote limit. Remove an existing vote first.": "Se alcanzó el límite de votos. Elimina un voto existente primero.", + "Thread has not been found": "No se encontró el hilo", + "Wait until all attachments have uploaded": "Espera hasta que se hayan cargado todos los adjuntos", + "size limit": "límite de tamaño", + "unknown error": "error desconocido", + "unsupported file type": "tipo de archivo no compatible" } diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index 164e7c6436..254b164ced 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Notifications", + "a11y/Dismiss notification": "Fermer la notification", + "Attachment upload blocked due to {{reason}}": "Envoi de la pièce jointe bloqué en raison de {{reason}}", + "Attachment upload failed due to {{reason}}": "Échec de l'envoi de la pièce jointe en raison de {{reason}}", + "Command not available": "Commande non disponible", + "Command not available while editing": "Commande non disponible pendant la modification", + "Command not available while replying": "Commande non disponible pendant la réponse", + "Error reproducing the recording": "Erreur lors de la lecture de l'enregistrement", + "Error uploading attachment": "Erreur lors de l'envoi de la pièce jointe", + "Failed to create the poll": "Échec de la création du sondage", + "Failed to create the poll due to {{reason}}": "Échec de la création du sondage en raison de {{reason}}", + "Failed to end the poll": "Échec de la clôture du sondage", + "Failed to end the poll due to {{reason}}": "Échec de la clôture du sondage en raison de {{reason}}", + "Failed to jump to the first unread message": "Impossible d'accéder au premier message non lu", + "Failed to retrieve location": "Impossible de récupérer la position", + "Failed to share location": "Impossible de partager la position", + "File is required for upload attachment": "Un fichier est requis pour envoyer une pièce jointe", + "Local upload attachment missing local id": "L'identifiant local de la pièce jointe à envoyer est manquant", + "Poll ended": "Sondage terminé", + "Reached the vote limit. Remove an existing vote first.": "Limite de votes atteinte. Supprimez d'abord un vote existant.", + "Thread has not been found": "Le fil de discussion est introuvable", + "Wait until all attachments have uploaded": "Attendez que toutes les pièces jointes soient envoyées", + "size limit": "limite de taille", + "unknown error": "erreur inconnue", + "unsupported file type": "type de fichier non pris en charge" } diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index 6a5f4935be..8ef333e2b5 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "התראות", + "a11y/Dismiss notification": "סגור התראה", + "Attachment upload blocked due to {{reason}}": "העלאת הקובץ המצורף נחסמה עקב {{reason}}", + "Attachment upload failed due to {{reason}}": "העלאת הקובץ המצורף נכשלה עקב {{reason}}", + "Command not available": "הפקודה אינה זמינה", + "Command not available while editing": "הפקודה אינה זמינה בזמן עריכה", + "Command not available while replying": "הפקודה אינה זמינה בזמן תגובה", + "Error reproducing the recording": "שגיאה בהפעלת ההקלטה", + "Error uploading attachment": "שגיאה בהעלאת הקובץ המצורף", + "Failed to create the poll": "יצירת הסקר נכשלה", + "Failed to create the poll due to {{reason}}": "יצירת הסקר נכשלה עקב {{reason}}", + "Failed to end the poll": "סיום הסקר נכשל", + "Failed to end the poll due to {{reason}}": "סיום הסקר נכשל עקב {{reason}}", + "Failed to jump to the first unread message": "המעבר להודעה הראשונה שלא נקראה נכשל", + "Failed to retrieve location": "אחזור המיקום נכשל", + "Failed to share location": "שיתוף המיקום נכשל", + "File is required for upload attachment": "נדרש קובץ להעלאת קובץ מצורף", + "Local upload attachment missing local id": "חסר מזהה מקומי לקובץ המצורף המקומי להעלאה", + "Poll ended": "הסקר הסתיים", + "Reached the vote limit. Remove an existing vote first.": "הגעת למגבלת ההצבעות. הסר קודם הצבעה קיימת.", + "Thread has not been found": "השרשור לא נמצא", + "Wait until all attachments have uploaded": "יש להמתין עד שכל הקבצים המצורפים יועלו", + "size limit": "מגבלת גודל", + "unknown error": "שגיאה לא ידועה", + "unsupported file type": "סוג קובץ לא נתמך" } diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index 849be7797d..df8bc2ae0b 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "सूचनाएं", + "a11y/Dismiss notification": "सूचना हटाएं", + "Attachment upload blocked due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड अवरुद्ध है", + "Attachment upload failed due to {{reason}}": "{{reason}} के कारण अटैचमेंट अपलोड विफल रहा", + "Command not available": "कमांड उपलब्ध नहीं है", + "Command not available while editing": "संपादन करते समय कमांड उपलब्ध नहीं है", + "Command not available while replying": "जवाब देते समय कमांड उपलब्ध नहीं है", + "Error reproducing the recording": "रिकॉर्डिंग चलाने में त्रुटि", + "Error uploading attachment": "अटैचमेंट अपलोड करने में त्रुटि", + "Failed to create the poll": "पोल बनाने में विफल", + "Failed to create the poll due to {{reason}}": "{{reason}} के कारण पोल बनाने में विफल", + "Failed to end the poll": "पोल समाप्त करने में विफल", + "Failed to end the poll due to {{reason}}": "{{reason}} के कारण पोल समाप्त करने में विफल", + "Failed to jump to the first unread message": "पहले अपठित संदेश पर जाने में विफल", + "Failed to retrieve location": "स्थान प्राप्त करने में विफल", + "Failed to share location": "स्थान साझा करने में विफल", + "File is required for upload attachment": "अटैचमेंट अपलोड करने के लिए फ़ाइल आवश्यक है", + "Local upload attachment missing local id": "स्थानीय अपलोड अटैचमेंट में स्थानीय आईडी नहीं है", + "Poll ended": "पोल समाप्त हो गया", + "Reached the vote limit. Remove an existing vote first.": "वोट सीमा पूरी हो गई है। पहले मौजूदा वोट हटाएं।", + "Thread has not been found": "थ्रेड नहीं मिला", + "Wait until all attachments have uploaded": "सभी अटैचमेंट अपलोड होने तक प्रतीक्षा करें", + "size limit": "आकार सीमा", + "unknown error": "अज्ञात त्रुटि", + "unsupported file type": "असमर्थित फ़ाइल प्रकार" } diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index a859105182..f2cfc80302 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Notifiche", + "a11y/Dismiss notification": "Chiudi notifica", + "Attachment upload blocked due to {{reason}}": "Caricamento dell'allegato bloccato a causa di {{reason}}", + "Attachment upload failed due to {{reason}}": "Caricamento dell'allegato non riuscito a causa di {{reason}}", + "Command not available": "Comando non disponibile", + "Command not available while editing": "Comando non disponibile durante la modifica", + "Command not available while replying": "Comando non disponibile durante la risposta", + "Error reproducing the recording": "Errore durante la riproduzione della registrazione", + "Error uploading attachment": "Errore durante il caricamento dell'allegato", + "Failed to create the poll": "Impossibile creare il sondaggio", + "Failed to create the poll due to {{reason}}": "Impossibile creare il sondaggio a causa di {{reason}}", + "Failed to end the poll": "Impossibile terminare il sondaggio", + "Failed to end the poll due to {{reason}}": "Impossibile terminare il sondaggio a causa di {{reason}}", + "Failed to jump to the first unread message": "Impossibile passare al primo messaggio non letto", + "Failed to retrieve location": "Impossibile recuperare la posizione", + "Failed to share location": "Impossibile condividere la posizione", + "File is required for upload attachment": "È necessario un file per caricare un allegato", + "Local upload attachment missing local id": "ID locale mancante per l'allegato locale da caricare", + "Poll ended": "Sondaggio terminato", + "Reached the vote limit. Remove an existing vote first.": "Limite di voti raggiunto. Rimuovi prima un voto esistente.", + "Thread has not been found": "Thread non trovato", + "Wait until all attachments have uploaded": "Attendi il caricamento di tutti gli allegati", + "size limit": "limite di dimensione", + "unknown error": "errore sconosciuto", + "unsupported file type": "tipo di file non supportato" } diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 12f181c402..e16bc8f0aa 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "通知", + "a11y/Dismiss notification": "通知を閉じる", + "Attachment upload blocked due to {{reason}}": "{{reason}} のため添付ファイルのアップロードがブロックされました", + "Attachment upload failed due to {{reason}}": "{{reason}} のため添付ファイルのアップロードに失敗しました", + "Command not available": "コマンドは利用できません", + "Command not available while editing": "編集中はコマンドを利用できません", + "Command not available while replying": "返信中はコマンドを利用できません", + "Error reproducing the recording": "録音の再生中にエラーが発生しました", + "Error uploading attachment": "添付ファイルのアップロード中にエラーが発生しました", + "Failed to create the poll": "投票を作成できませんでした", + "Failed to create the poll due to {{reason}}": "{{reason}} のため投票を作成できませんでした", + "Failed to end the poll": "投票を終了できませんでした", + "Failed to end the poll due to {{reason}}": "{{reason}} のため投票を終了できませんでした", + "Failed to jump to the first unread message": "最初の未読メッセージへ移動できませんでした", + "Failed to retrieve location": "位置情報を取得できませんでした", + "Failed to share location": "位置情報を共有できませんでした", + "File is required for upload attachment": "添付ファイルをアップロードするにはファイルが必要です", + "Local upload attachment missing local id": "ローカルアップロード添付ファイルにローカル ID がありません", + "Poll ended": "投票は終了しました", + "Reached the vote limit. Remove an existing vote first.": "投票数の上限に達しました。先に既存の投票を削除してください。", + "Thread has not been found": "スレッドが見つかりません", + "Wait until all attachments have uploaded": "すべての添付ファイルのアップロードが完了するまでお待ちください", + "size limit": "サイズ制限", + "unknown error": "不明なエラー", + "unsupported file type": "サポートされていないファイル形式" } diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 99828dd22b..71f294315a 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "알림", + "a11y/Dismiss notification": "알림 닫기", + "Attachment upload blocked due to {{reason}}": "{{reason}} 때문에 첨부 파일 업로드가 차단되었습니다", + "Attachment upload failed due to {{reason}}": "{{reason}} 때문에 첨부 파일 업로드에 실패했습니다", + "Command not available": "명령을 사용할 수 없습니다", + "Command not available while editing": "편집 중에는 명령을 사용할 수 없습니다", + "Command not available while replying": "답장 중에는 명령을 사용할 수 없습니다", + "Error reproducing the recording": "녹음 재생 중 오류가 발생했습니다", + "Error uploading attachment": "첨부 파일 업로드 중 오류가 발생했습니다", + "Failed to create the poll": "투표를 만들지 못했습니다", + "Failed to create the poll due to {{reason}}": "{{reason}} 때문에 투표를 만들지 못했습니다", + "Failed to end the poll": "투표를 종료하지 못했습니다", + "Failed to end the poll due to {{reason}}": "{{reason}} 때문에 투표를 종료하지 못했습니다", + "Failed to jump to the first unread message": "첫 번째 읽지 않은 메시지로 이동하지 못했습니다", + "Failed to retrieve location": "위치를 가져오지 못했습니다", + "Failed to share location": "위치를 공유하지 못했습니다", + "File is required for upload attachment": "첨부 파일을 업로드하려면 파일이 필요합니다", + "Local upload attachment missing local id": "로컬 업로드 첨부 파일에 로컬 ID가 없습니다", + "Poll ended": "투표가 종료되었습니다", + "Reached the vote limit. Remove an existing vote first.": "투표 한도에 도달했습니다. 먼저 기존 투표를 제거하세요.", + "Thread has not been found": "스레드를 찾을 수 없습니다", + "Wait until all attachments have uploaded": "모든 첨부 파일이 업로드될 때까지 기다리세요", + "size limit": "크기 제한", + "unknown error": "알 수 없는 오류", + "unsupported file type": "지원되지 않는 파일 형식" } diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index 72b4993f8d..43936df757 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Meldingen", + "a11y/Dismiss notification": "Melding sluiten", + "Attachment upload blocked due to {{reason}}": "Uploaden van bijlage geblokkeerd vanwege {{reason}}", + "Attachment upload failed due to {{reason}}": "Uploaden van bijlage mislukt vanwege {{reason}}", + "Command not available": "Opdracht niet beschikbaar", + "Command not available while editing": "Opdracht niet beschikbaar tijdens bewerken", + "Command not available while replying": "Opdracht niet beschikbaar tijdens beantwoorden", + "Error reproducing the recording": "Fout bij afspelen van de opname", + "Error uploading attachment": "Fout bij uploaden van bijlage", + "Failed to create the poll": "Poll maken mislukt", + "Failed to create the poll due to {{reason}}": "Poll maken mislukt vanwege {{reason}}", + "Failed to end the poll": "Poll beëindigen mislukt", + "Failed to end the poll due to {{reason}}": "Poll beëindigen mislukt vanwege {{reason}}", + "Failed to jump to the first unread message": "Kon niet naar het eerste ongelezen bericht gaan", + "Failed to retrieve location": "Kon locatie niet ophalen", + "Failed to share location": "Kon locatie niet delen", + "File is required for upload attachment": "Er is een bestand vereist om een bijlage te uploaden", + "Local upload attachment missing local id": "Lokale uploadbijlage mist een lokale ID", + "Poll ended": "Poll beëindigd", + "Reached the vote limit. Remove an existing vote first.": "Stemlimiet bereikt. Verwijder eerst een bestaande stem.", + "Thread has not been found": "Thread is niet gevonden", + "Wait until all attachments have uploaded": "Wacht tot alle bijlagen zijn geüpload", + "size limit": "groottelimiet", + "unknown error": "onbekende fout", + "unsupported file type": "niet-ondersteund bestandstype" } diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index 4dfed9d688..cf4febe0da 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Notificações", + "a11y/Dismiss notification": "Fechar notificação", + "Attachment upload blocked due to {{reason}}": "Upload do anexo bloqueado devido a {{reason}}", + "Attachment upload failed due to {{reason}}": "Falha no upload do anexo devido a {{reason}}", + "Command not available": "Comando indisponível", + "Command not available while editing": "Comando indisponível durante a edição", + "Command not available while replying": "Comando indisponível durante a resposta", + "Error reproducing the recording": "Erro ao reproduzir a gravação", + "Error uploading attachment": "Erro ao fazer upload do anexo", + "Failed to create the poll": "Falha ao criar a enquete", + "Failed to create the poll due to {{reason}}": "Falha ao criar a enquete devido a {{reason}}", + "Failed to end the poll": "Falha ao encerrar a enquete", + "Failed to end the poll due to {{reason}}": "Falha ao encerrar a enquete devido a {{reason}}", + "Failed to jump to the first unread message": "Falha ao ir para a primeira mensagem não lida", + "Failed to retrieve location": "Falha ao obter localização", + "Failed to share location": "Falha ao compartilhar localização", + "File is required for upload attachment": "É necessário um arquivo para fazer upload de um anexo", + "Local upload attachment missing local id": "ID local ausente no anexo local para upload", + "Poll ended": "Enquete encerrada", + "Reached the vote limit. Remove an existing vote first.": "Limite de votos atingido. Remova um voto existente primeiro.", + "Thread has not been found": "Thread não encontrada", + "Wait until all attachments have uploaded": "Aguarde até que todos os anexos tenham sido enviados", + "size limit": "limite de tamanho", + "unknown error": "erro desconhecido", + "unsupported file type": "tipo de arquivo não compatível" } diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 85e7839b55..897274a3b7 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Уведомления", + "a11y/Dismiss notification": "Закрыть уведомление", + "Attachment upload blocked due to {{reason}}": "Загрузка вложения заблокирована из-за {{reason}}", + "Attachment upload failed due to {{reason}}": "Не удалось загрузить вложение из-за {{reason}}", + "Command not available": "Команда недоступна", + "Command not available while editing": "Команда недоступна во время редактирования", + "Command not available while replying": "Команда недоступна во время ответа", + "Error reproducing the recording": "Ошибка воспроизведения записи", + "Error uploading attachment": "Ошибка загрузки вложения", + "Failed to create the poll": "Не удалось создать опрос", + "Failed to create the poll due to {{reason}}": "Не удалось создать опрос из-за {{reason}}", + "Failed to end the poll": "Не удалось завершить опрос", + "Failed to end the poll due to {{reason}}": "Не удалось завершить опрос из-за {{reason}}", + "Failed to jump to the first unread message": "Не удалось перейти к первому непрочитанному сообщению", + "Failed to retrieve location": "Не удалось получить местоположение", + "Failed to share location": "Не удалось поделиться местоположением", + "File is required for upload attachment": "Для загрузки вложения требуется файл", + "Local upload attachment missing local id": "У локального загружаемого вложения отсутствует локальный ID", + "Poll ended": "Опрос завершен", + "Reached the vote limit. Remove an existing vote first.": "Достигнут лимит голосов. Сначала удалите существующий голос.", + "Thread has not been found": "Ветка не найдена", + "Wait until all attachments have uploaded": "Дождитесь загрузки всех вложений", + "size limit": "лимит размера", + "unknown error": "неизвестная ошибка", + "unsupported file type": "неподдерживаемый тип файла" } diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index f9ee188465..1ba835d38d 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -292,5 +292,30 @@ "a11y/Send voice recording": "Send voice recording", "a11y/Share Button": "Share Button", "a11y/Start voice recording": "Start voice recording", - "a11y/Stop voice recording": "Stop voice recording" + "a11y/Stop voice recording": "Stop voice recording", + "a11y/Notifications": "Bildirimler", + "a11y/Dismiss notification": "Bildirimi kapat", + "Attachment upload blocked due to {{reason}}": "Ek yükleme {{reason}} nedeniyle engellendi", + "Attachment upload failed due to {{reason}}": "Ek yükleme {{reason}} nedeniyle başarısız oldu", + "Command not available": "Komut kullanılamıyor", + "Command not available while editing": "Komut düzenleme sırasında kullanılamıyor", + "Command not available while replying": "Komut yanıt verirken kullanılamıyor", + "Error reproducing the recording": "Kaydı oynatırken hata oluştu", + "Error uploading attachment": "Eki yüklerken hata oluştu", + "Failed to create the poll": "Anket oluşturulamadı", + "Failed to create the poll due to {{reason}}": "Anket {{reason}} nedeniyle oluşturulamadı", + "Failed to end the poll": "Anket sonlandırılamadı", + "Failed to end the poll due to {{reason}}": "Anket {{reason}} nedeniyle sonlandırılamadı", + "Failed to jump to the first unread message": "İlk okunmamış mesaja gidilemedi", + "Failed to retrieve location": "Konum alınamadı", + "Failed to share location": "Konum paylaşılamadı", + "File is required for upload attachment": "Ek yüklemek için bir dosya gerekli", + "Local upload attachment missing local id": "Yerel yükleme ekinin yerel kimliği eksik", + "Poll ended": "Anket sona erdi", + "Reached the vote limit. Remove an existing vote first.": "Oy sınırına ulaşıldı. Önce mevcut bir oyu kaldırın.", + "Thread has not been found": "Konu bulunamadı", + "Wait until all attachments have uploaded": "Tüm ekler yüklenene kadar bekleyin", + "size limit": "boyut sınırı", + "unknown error": "bilinmeyen hata", + "unsupported file type": "desteklenmeyen dosya türü" } diff --git a/package/yarn.lock b/package/yarn.lock index 14eff97d5e..04f92612f3 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8507,10 +8507,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -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" From 32984e4cace33d2ec16583f906c36b53986b8519 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 14:40:05 +0200 Subject: [PATCH 5/8] fix: disabled command snackbars --- .../components/AttachmentPickerContent.tsx | 6 +-- .../AttachmentPickerContent.test.tsx | 37 ++++++++++--------- 2 files changed, 22 insertions(+), 21 deletions(-) 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(); From 00a34c582885d08e097fead73798bc50362dc237 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 15:36:56 +0200 Subject: [PATCH 6/8] chore: bump sample app yarn.lock --- examples/SampleApp/yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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" From ada17af02acccf45a26087da814e4bd2caf634f7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 8 May 2026 16:30:22 +0200 Subject: [PATCH 7/8] feat: add missing sdk notification invocations --- .../useMessageListPagination.test.tsx | 31 ++- .../hooks/useMessageListPagination.tsx | 22 +- .../__tests__/useChannelActions.test.tsx | 123 +++++++++++ .../ChannelList/hooks/useChannelActions.ts | 199 ++++++++++++++++-- .../useMessageActionHandlers.test.tsx | 128 +++++++++++ .../Message/hooks/useMessageActionHandlers.ts | 160 ++++++++++++-- .../AudioRecorder/AudioRecorder.tsx | 13 +- .../MessageMenu/hooks/useFetchReactions.ts | 15 +- .../src/components/Poll/hooks/usePollState.ts | 27 ++- .../MessageInputContext.tsx | 24 +++ package/src/hooks/useAudioPlayer.ts | 45 +++- package/src/i18n/en.json | 33 +++ .../__tests__/audio-player.test.ts | 45 ++++ package/src/state-store/audio-player-pool.ts | 1 + package/src/state-store/audio-player.ts | 124 +++++++++-- 15 files changed, 925 insertions(+), 65 deletions(-) create mode 100644 package/src/components/ChannelList/hooks/__tests__/useChannelActions.test.tsx create mode 100644 package/src/components/Message/hooks/__tests__/useMessageActionHandlers.test.tsx 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/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 (