From bbc26b513d65716b588c95d079721bcbaea7cce1 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 27 Aug 2025 11:18:18 +0530 Subject: [PATCH 1/2] feat: add toast component to sample app to handle client notifications --- examples/SampleApp/App.tsx | 4 + .../src/components/ToastComponent/Toast.tsx | 97 +++++++++++++++++++ .../useClientNotificationsToastHandler.ts | 39 ++++++++ examples/SampleApp/src/hooks/useToastState.ts | 22 +++++ examples/SampleApp/src/store/toast-store.ts | 40 ++++++++ package/src/hooks/index.ts | 1 + package/src/hooks/useClientNotifications.ts | 21 ++++ 7 files changed, 224 insertions(+) create mode 100644 examples/SampleApp/src/components/ToastComponent/Toast.tsx create mode 100644 examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts create mode 100644 examples/SampleApp/src/hooks/useToastState.ts create mode 100644 examples/SampleApp/src/store/toast-store.ts create mode 100644 package/src/hooks/useClientNotifications.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 79bd3f171a..4b22343f7d 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -56,6 +56,8 @@ Geolocation.setRNConfiguration({ }); import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-chat'; +import { Toast } from './src/components/ToastComponent/Toast'; +import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler'; init({ data }); @@ -231,6 +233,7 @@ const DrawerNavigatorWrapper: React.FC<{ + @@ -258,6 +261,7 @@ const UserSelector = () => ( // TODO: Split the stack into multiple stacks - ChannelStack, CreateChannelStack etc. const HomeScreen = () => { const { overlay } = useOverlayContext(); + useClientNotificationsToastHandler(); return ( = { + error: '❌', + success: '✅', + warning: '⚠️', + info: 'ℹ️', +}; + +export const Toast = () => { + const { closeToast, notifications } = useToastState(); + const { top } = useSafeAreaInsets(); + const { + theme: { + colors: { overlay, white_smoke }, + }, + } = useTheme(); + + return ( + + {notifications.map((notification) => ( + + + + {severityIconMap[notification.severity]} + + + + {notification.message} + + closeToast(notification.id)}> + + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + right: 16, + left: 16, + alignItems: 'flex-end', + }, + toast: { + width: width * 0.9, + borderRadius: 12, + padding: 12, + marginBottom: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + shadowColor: '#000', + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 5, + }, + content: { + flex: 1, + marginHorizontal: 8, + }, + message: { + fontSize: 14, + fontWeight: '600', + }, + close: { + fontSize: 16, + }, + icon: { + width: 20, + height: 20, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + iconText: { + fontWeight: 'bold', + includeFontPadding: false, + }, + warning: { + backgroundColor: 'yellow', + }, +}); diff --git a/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts b/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts new file mode 100644 index 0000000000..6aadf7779f --- /dev/null +++ b/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts @@ -0,0 +1,39 @@ +import type { Notification } from 'stream-chat'; +import { useClientNotifications } from 'stream-chat-react-native'; + +import { useEffect, useMemo, useRef } from 'react'; +import { useToastState } from './useToastState'; + +export const usePreviousNotifications = (notifications: Notification[]) => { + const prevNotifications = useRef(notifications); + + const difference = useMemo(() => { + const prevIds = new Set(prevNotifications.current.map((notification) => notification.id)); + const newIds = new Set(notifications.map((notification) => notification.id)); + return { + added: notifications.filter((notification) => !prevIds.has(notification.id)), + removed: prevNotifications.current.filter((notification) => !newIds.has(notification.id)), + }; + }, [notifications]); + + useEffect(() => { + prevNotifications.current = notifications; + }, [notifications]); + + return difference; +}; + +/** + * This hook is used to open and close the toast notifications when the notifications are added or removed. + * @returns {void} + */ +export const useClientNotificationsToastHandler = () => { + const { notifications } = useClientNotifications(); + const { openToast, closeToast } = useToastState(); + const { added, removed } = usePreviousNotifications(notifications); + + useEffect(() => { + added.forEach(openToast); + removed.forEach((notification) => closeToast(notification.id)); + }, [added, closeToast, openToast, removed]); +}; diff --git a/examples/SampleApp/src/hooks/useToastState.ts b/examples/SampleApp/src/hooks/useToastState.ts new file mode 100644 index 0000000000..f3429d0664 --- /dev/null +++ b/examples/SampleApp/src/hooks/useToastState.ts @@ -0,0 +1,22 @@ +import { Notification } from 'stream-chat'; +import { useStableCallback, useStateStore } from 'stream-chat-react-native'; +import type { ToastState } from '../store/toast-store'; +import { toastStore, openToast, closeToast } from '../store/toast-store'; + +const selector = ({ notifications }: ToastState) => ({ + notifications, +}); + +export const useToastState = () => { + const { notifications } = useStateStore(toastStore, selector); + + const openToastInternal = useStableCallback((notificationData: Notification) => { + openToast(notificationData); + }); + + const closeToastInternal = useStableCallback((id: string) => { + closeToast(id); + }); + + return { notifications, openToast: openToastInternal, closeToast: closeToastInternal }; +}; diff --git a/examples/SampleApp/src/store/toast-store.ts b/examples/SampleApp/src/store/toast-store.ts new file mode 100644 index 0000000000..f70ebeea01 --- /dev/null +++ b/examples/SampleApp/src/store/toast-store.ts @@ -0,0 +1,40 @@ +import { Notification, StateStore } from 'stream-chat'; + +export type ToastState = { + notifications: Notification[]; +}; + +const INITIAL_STATE: ToastState = { + notifications: [], +}; + +export const toastStore = new StateStore(INITIAL_STATE); + +export const openToast = (notification: Notification) => { + if (!notification.id) { + console.warn('Notification must have an id to be opened!'); + return; + } + const { notifications } = toastStore.getLatestValue(); + + // Prevent duplicate notifications + if (notifications.some((n) => n.id === notification.id)) { + console.warn('Notification with the same id already exists!'); + return; + } + + toastStore.partialNext({ + notifications: [...notifications, notification], + }); +}; + +export const closeToast = (id: string) => { + if (!id) { + console.warn('Notification id is required to be closed!'); + return; + } + const { notifications } = toastStore.getLatestValue(); + toastStore.partialNext({ + notifications: notifications.filter((notification) => notification.id !== id), + }); +}; diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 913f7edbcd..09060a6825 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -7,3 +7,4 @@ export * from './useStableCallback'; export * from './useLoadingImage'; export * from './useMessageReminder'; export * from './useQueryReminders'; +export * from './useClientNotifications'; diff --git a/package/src/hooks/useClientNotifications.ts b/package/src/hooks/useClientNotifications.ts new file mode 100644 index 0000000000..dca807148e --- /dev/null +++ b/package/src/hooks/useClientNotifications.ts @@ -0,0 +1,21 @@ +import type { NotificationManagerState } from 'stream-chat'; + +import { useStateStore } from './useStateStore'; + +import { useChatContext } from '../contexts/chatContext/ChatContext'; + +const selector = (state: NotificationManagerState) => ({ + notifications: state.notifications, +}); + +/** + * This hook is used to get the notifications from the client. + * @returns {Object} - An object containing the notifications. + * @returns {Notification[]} notifications - The notifications. + */ +export const useClientNotifications = () => { + const { client } = useChatContext(); + const { notifications } = useStateStore(client.notifications.store, selector); + + return { notifications }; +}; From dbee91c07d02861705276f9fa50dbf804c2a680b Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Wed, 27 Aug 2025 18:14:08 +0530 Subject: [PATCH 2/2] feat: in app notifications store --- .../src/components/ToastComponent/Toast.tsx | 7 ++-- .../useClientNotificationsToastHandler.ts | 15 ++++----- examples/SampleApp/src/hooks/useToastState.ts | 22 ------------- package/src/hooks/index.ts | 1 + .../src/hooks/useInAppNotificationsState.ts | 33 +++++++++++++++++++ package/src/index.ts | 1 + .../src/store/in-app-notifications-store.ts | 18 +++++----- package/src/store/index.ts | 1 + 8 files changed, 54 insertions(+), 44 deletions(-) delete mode 100644 examples/SampleApp/src/hooks/useToastState.ts create mode 100644 package/src/hooks/useInAppNotificationsState.ts rename examples/SampleApp/src/store/toast-store.ts => package/src/store/in-app-notifications-store.ts (55%) create mode 100644 package/src/store/index.ts diff --git a/examples/SampleApp/src/components/ToastComponent/Toast.tsx b/examples/SampleApp/src/components/ToastComponent/Toast.tsx index 2b81d9002b..60f07da821 100644 --- a/examples/SampleApp/src/components/ToastComponent/Toast.tsx +++ b/examples/SampleApp/src/components/ToastComponent/Toast.tsx @@ -1,8 +1,7 @@ import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { useToastState } from '../../hooks/useToastState'; import Animated, { Easing, SlideInDown, SlideOutDown } from 'react-native-reanimated'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTheme } from 'stream-chat-react-native'; +import { useInAppNotificationsState, useTheme } from 'stream-chat-react-native'; import type { Notification } from 'stream-chat'; const { width } = Dimensions.get('window'); @@ -15,7 +14,7 @@ const severityIconMap: Record = { }; export const Toast = () => { - const { closeToast, notifications } = useToastState(); + const { closeInAppNotification, notifications } = useInAppNotificationsState(); const { top } = useSafeAreaInsets(); const { theme: { @@ -40,7 +39,7 @@ export const Toast = () => { {notification.message} - closeToast(notification.id)}> + closeInAppNotification(notification.id)}> diff --git a/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts b/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts index 6aadf7779f..931571bddf 100644 --- a/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts +++ b/examples/SampleApp/src/hooks/useClientNotificationsToastHandler.ts @@ -1,8 +1,7 @@ import type { Notification } from 'stream-chat'; -import { useClientNotifications } from 'stream-chat-react-native'; +import { useClientNotifications, useInAppNotificationsState } from 'stream-chat-react-native'; import { useEffect, useMemo, useRef } from 'react'; -import { useToastState } from './useToastState'; export const usePreviousNotifications = (notifications: Notification[]) => { const prevNotifications = useRef(notifications); @@ -16,9 +15,7 @@ export const usePreviousNotifications = (notifications: Notification[]) => { }; }, [notifications]); - useEffect(() => { - prevNotifications.current = notifications; - }, [notifications]); + prevNotifications.current = notifications; return difference; }; @@ -29,11 +26,11 @@ export const usePreviousNotifications = (notifications: Notification[]) => { */ export const useClientNotificationsToastHandler = () => { const { notifications } = useClientNotifications(); - const { openToast, closeToast } = useToastState(); + const { openInAppNotification, closeInAppNotification } = useInAppNotificationsState(); const { added, removed } = usePreviousNotifications(notifications); useEffect(() => { - added.forEach(openToast); - removed.forEach((notification) => closeToast(notification.id)); - }, [added, closeToast, openToast, removed]); + added.forEach(openInAppNotification); + removed.forEach((notification) => closeInAppNotification(notification.id)); + }, [added, closeInAppNotification, openInAppNotification, removed]); }; diff --git a/examples/SampleApp/src/hooks/useToastState.ts b/examples/SampleApp/src/hooks/useToastState.ts deleted file mode 100644 index f3429d0664..0000000000 --- a/examples/SampleApp/src/hooks/useToastState.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Notification } from 'stream-chat'; -import { useStableCallback, useStateStore } from 'stream-chat-react-native'; -import type { ToastState } from '../store/toast-store'; -import { toastStore, openToast, closeToast } from '../store/toast-store'; - -const selector = ({ notifications }: ToastState) => ({ - notifications, -}); - -export const useToastState = () => { - const { notifications } = useStateStore(toastStore, selector); - - const openToastInternal = useStableCallback((notificationData: Notification) => { - openToast(notificationData); - }); - - const closeToastInternal = useStableCallback((id: string) => { - closeToast(id); - }); - - return { notifications, openToast: openToastInternal, closeToast: closeToastInternal }; -}; diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 09060a6825..76a6febf47 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useLoadingImage'; export * from './useMessageReminder'; export * from './useQueryReminders'; export * from './useClientNotifications'; +export * from './useInAppNotificationsState'; diff --git a/package/src/hooks/useInAppNotificationsState.ts b/package/src/hooks/useInAppNotificationsState.ts new file mode 100644 index 0000000000..2e4237f9ee --- /dev/null +++ b/package/src/hooks/useInAppNotificationsState.ts @@ -0,0 +1,33 @@ +import { Notification } from 'stream-chat'; + +import { useStableCallback } from './useStableCallback'; +import { useStateStore } from './useStateStore'; + +import type { InAppNotificationsState } from '../store/in-app-notifications-store'; +import { + closeInAppNotification, + inAppNotificationsStore, + openInAppNotification, +} from '../store/in-app-notifications-store'; + +const selector = ({ notifications }: InAppNotificationsState) => ({ + notifications, +}); + +export const useInAppNotificationsState = () => { + const { notifications } = useStateStore(inAppNotificationsStore, selector); + + const openInAppNotificationInternal = useStableCallback((notificationData: Notification) => { + openInAppNotification(notificationData); + }); + + const closeInAppNotificationInternal = useStableCallback((id: string) => { + closeInAppNotification(id); + }); + + return { + closeInAppNotification: closeInAppNotificationInternal, + notifications, + openInAppNotification: openInAppNotificationInternal, + }; +}; diff --git a/package/src/index.ts b/package/src/index.ts index 3e5ae5e460..db05625136 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -31,6 +31,7 @@ export { default as ptBRTranslations } from './i18n/pt-br.json'; export { default as ruTranslations } from './i18n/ru.json'; export { default as trTranslations } from './i18n/tr.json'; +export * from './store'; export { SqliteClient } from './store/SqliteClient'; export { OfflineDB } from './store/OfflineDB'; export { version } from './version.json'; diff --git a/examples/SampleApp/src/store/toast-store.ts b/package/src/store/in-app-notifications-store.ts similarity index 55% rename from examples/SampleApp/src/store/toast-store.ts rename to package/src/store/in-app-notifications-store.ts index f70ebeea01..f5fe357696 100644 --- a/examples/SampleApp/src/store/toast-store.ts +++ b/package/src/store/in-app-notifications-store.ts @@ -1,21 +1,21 @@ import { Notification, StateStore } from 'stream-chat'; -export type ToastState = { +export type InAppNotificationsState = { notifications: Notification[]; }; -const INITIAL_STATE: ToastState = { +const INITIAL_STATE: InAppNotificationsState = { notifications: [], }; -export const toastStore = new StateStore(INITIAL_STATE); +export const inAppNotificationsStore = new StateStore(INITIAL_STATE); -export const openToast = (notification: Notification) => { +export const openInAppNotification = (notification: Notification) => { if (!notification.id) { console.warn('Notification must have an id to be opened!'); return; } - const { notifications } = toastStore.getLatestValue(); + const { notifications } = inAppNotificationsStore.getLatestValue(); // Prevent duplicate notifications if (notifications.some((n) => n.id === notification.id)) { @@ -23,18 +23,18 @@ export const openToast = (notification: Notification) => { return; } - toastStore.partialNext({ + inAppNotificationsStore.partialNext({ notifications: [...notifications, notification], }); }; -export const closeToast = (id: string) => { +export const closeInAppNotification = (id: string) => { if (!id) { console.warn('Notification id is required to be closed!'); return; } - const { notifications } = toastStore.getLatestValue(); - toastStore.partialNext({ + const { notifications } = inAppNotificationsStore.getLatestValue(); + inAppNotificationsStore.partialNext({ notifications: notifications.filter((notification) => notification.id !== id), }); }; diff --git a/package/src/store/index.ts b/package/src/store/index.ts new file mode 100644 index 0000000000..1ab4b645f7 --- /dev/null +++ b/package/src/store/index.ts @@ -0,0 +1 @@ +export * from './in-app-notifications-store';