Skip to content

Commit 1f7e056

Browse files
authored
feat: add toast component to sample app to handle client notifications (#3204)
* feat: add toast component to sample app to handle client notifications * feat: in app notifications store
1 parent 9d498c3 commit 1f7e056

File tree

9 files changed

+234
-0
lines changed

9 files changed

+234
-0
lines changed

examples/SampleApp/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ Geolocation.setRNConfiguration({
5656
});
5757

5858
import type { LocalMessage, StreamChat, TextComposerMiddleware } from 'stream-chat';
59+
import { Toast } from './src/components/ToastComponent/Toast';
60+
import { useClientNotificationsToastHandler } from './src/hooks/useClientNotificationsToastHandler';
5961

6062
init({ data });
6163

@@ -231,6 +233,7 @@ const DrawerNavigatorWrapper: React.FC<{
231233
<AppOverlayProvider>
232234
<UserSearchProvider>
233235
<DrawerNavigator />
236+
<Toast />
234237
</UserSearchProvider>
235238
</AppOverlayProvider>
236239
</StreamChatProvider>
@@ -258,6 +261,7 @@ const UserSelector = () => (
258261
// TODO: Split the stack into multiple stacks - ChannelStack, CreateChannelStack etc.
259262
const HomeScreen = () => {
260263
const { overlay } = useOverlayContext();
264+
useClientNotificationsToastHandler();
261265

262266
return (
263267
<Stack.Navigator
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2+
import Animated, { Easing, SlideInDown, SlideOutDown } from 'react-native-reanimated';
3+
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
4+
import { useInAppNotificationsState, useTheme } from 'stream-chat-react-native';
5+
import type { Notification } from 'stream-chat';
6+
7+
const { width } = Dimensions.get('window');
8+
9+
const severityIconMap: Record<Notification['severity'], string> = {
10+
error: '❌',
11+
success: '✅',
12+
warning: '⚠️',
13+
info: 'ℹ️',
14+
};
15+
16+
export const Toast = () => {
17+
const { closeInAppNotification, notifications } = useInAppNotificationsState();
18+
const { top } = useSafeAreaInsets();
19+
const {
20+
theme: {
21+
colors: { overlay, white_smoke },
22+
},
23+
} = useTheme();
24+
25+
return (
26+
<SafeAreaView style={[styles.container, { top }]} pointerEvents='box-none'>
27+
{notifications.map((notification) => (
28+
<Animated.View
29+
key={notification.id}
30+
entering={SlideInDown.easing(Easing.bezierFn(0.25, 0.1, 0.25, 1.0))}
31+
exiting={SlideOutDown}
32+
style={[styles.toast, { backgroundColor: overlay }]}
33+
>
34+
<View style={[styles.icon, { backgroundColor: overlay }]}>
35+
<Text style={[styles.iconText, { color: white_smoke }]}>
36+
{severityIconMap[notification.severity]}
37+
</Text>
38+
</View>
39+
<View style={styles.content}>
40+
<Text style={[styles.message, { color: white_smoke }]}>{notification.message}</Text>
41+
</View>
42+
<TouchableOpacity onPress={() => closeInAppNotification(notification.id)}>
43+
<Text style={[styles.close, { color: white_smoke }]}></Text>
44+
</TouchableOpacity>
45+
</Animated.View>
46+
))}
47+
</SafeAreaView>
48+
);
49+
};
50+
51+
const styles = StyleSheet.create({
52+
container: {
53+
position: 'absolute',
54+
right: 16,
55+
left: 16,
56+
alignItems: 'flex-end',
57+
},
58+
toast: {
59+
width: width * 0.9,
60+
borderRadius: 12,
61+
padding: 12,
62+
marginBottom: 8,
63+
flexDirection: 'row',
64+
justifyContent: 'space-between',
65+
alignItems: 'center',
66+
shadowColor: '#000',
67+
shadowOpacity: 0.2,
68+
shadowRadius: 4,
69+
elevation: 5,
70+
},
71+
content: {
72+
flex: 1,
73+
marginHorizontal: 8,
74+
},
75+
message: {
76+
fontSize: 14,
77+
fontWeight: '600',
78+
},
79+
close: {
80+
fontSize: 16,
81+
},
82+
icon: {
83+
width: 20,
84+
height: 20,
85+
borderRadius: 12,
86+
justifyContent: 'center',
87+
alignItems: 'center',
88+
},
89+
iconText: {
90+
fontWeight: 'bold',
91+
includeFontPadding: false,
92+
},
93+
warning: {
94+
backgroundColor: 'yellow',
95+
},
96+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { Notification } from 'stream-chat';
2+
import { useClientNotifications, useInAppNotificationsState } from 'stream-chat-react-native';
3+
4+
import { useEffect, useMemo, useRef } from 'react';
5+
6+
export const usePreviousNotifications = (notifications: Notification[]) => {
7+
const prevNotifications = useRef<Notification[]>(notifications);
8+
9+
const difference = useMemo(() => {
10+
const prevIds = new Set(prevNotifications.current.map((notification) => notification.id));
11+
const newIds = new Set(notifications.map((notification) => notification.id));
12+
return {
13+
added: notifications.filter((notification) => !prevIds.has(notification.id)),
14+
removed: prevNotifications.current.filter((notification) => !newIds.has(notification.id)),
15+
};
16+
}, [notifications]);
17+
18+
prevNotifications.current = notifications;
19+
20+
return difference;
21+
};
22+
23+
/**
24+
* This hook is used to open and close the toast notifications when the notifications are added or removed.
25+
* @returns {void}
26+
*/
27+
export const useClientNotificationsToastHandler = () => {
28+
const { notifications } = useClientNotifications();
29+
const { openInAppNotification, closeInAppNotification } = useInAppNotificationsState();
30+
const { added, removed } = usePreviousNotifications(notifications);
31+
32+
useEffect(() => {
33+
added.forEach(openInAppNotification);
34+
removed.forEach((notification) => closeInAppNotification(notification.id));
35+
}, [added, closeInAppNotification, openInAppNotification, removed]);
36+
};

package/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ export * from './useStableCallback';
77
export * from './useLoadingImage';
88
export * from './useMessageReminder';
99
export * from './useQueryReminders';
10+
export * from './useClientNotifications';
11+
export * from './useInAppNotificationsState';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { NotificationManagerState } from 'stream-chat';
2+
3+
import { useStateStore } from './useStateStore';
4+
5+
import { useChatContext } from '../contexts/chatContext/ChatContext';
6+
7+
const selector = (state: NotificationManagerState) => ({
8+
notifications: state.notifications,
9+
});
10+
11+
/**
12+
* This hook is used to get the notifications from the client.
13+
* @returns {Object} - An object containing the notifications.
14+
* @returns {Notification[]} notifications - The notifications.
15+
*/
16+
export const useClientNotifications = () => {
17+
const { client } = useChatContext();
18+
const { notifications } = useStateStore(client.notifications.store, selector);
19+
20+
return { notifications };
21+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Notification } from 'stream-chat';
2+
3+
import { useStableCallback } from './useStableCallback';
4+
import { useStateStore } from './useStateStore';
5+
6+
import type { InAppNotificationsState } from '../store/in-app-notifications-store';
7+
import {
8+
closeInAppNotification,
9+
inAppNotificationsStore,
10+
openInAppNotification,
11+
} from '../store/in-app-notifications-store';
12+
13+
const selector = ({ notifications }: InAppNotificationsState) => ({
14+
notifications,
15+
});
16+
17+
export const useInAppNotificationsState = () => {
18+
const { notifications } = useStateStore(inAppNotificationsStore, selector);
19+
20+
const openInAppNotificationInternal = useStableCallback((notificationData: Notification) => {
21+
openInAppNotification(notificationData);
22+
});
23+
24+
const closeInAppNotificationInternal = useStableCallback((id: string) => {
25+
closeInAppNotification(id);
26+
});
27+
28+
return {
29+
closeInAppNotification: closeInAppNotificationInternal,
30+
notifications,
31+
openInAppNotification: openInAppNotificationInternal,
32+
};
33+
};

package/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export { default as ptBRTranslations } from './i18n/pt-br.json';
3131
export { default as ruTranslations } from './i18n/ru.json';
3232
export { default as trTranslations } from './i18n/tr.json';
3333

34+
export * from './store';
3435
export { SqliteClient } from './store/SqliteClient';
3536
export { OfflineDB } from './store/OfflineDB';
3637
export { version } from './version.json';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Notification, StateStore } from 'stream-chat';
2+
3+
export type InAppNotificationsState = {
4+
notifications: Notification[];
5+
};
6+
7+
const INITIAL_STATE: InAppNotificationsState = {
8+
notifications: [],
9+
};
10+
11+
export const inAppNotificationsStore = new StateStore<InAppNotificationsState>(INITIAL_STATE);
12+
13+
export const openInAppNotification = (notification: Notification) => {
14+
if (!notification.id) {
15+
console.warn('Notification must have an id to be opened!');
16+
return;
17+
}
18+
const { notifications } = inAppNotificationsStore.getLatestValue();
19+
20+
// Prevent duplicate notifications
21+
if (notifications.some((n) => n.id === notification.id)) {
22+
console.warn('Notification with the same id already exists!');
23+
return;
24+
}
25+
26+
inAppNotificationsStore.partialNext({
27+
notifications: [...notifications, notification],
28+
});
29+
};
30+
31+
export const closeInAppNotification = (id: string) => {
32+
if (!id) {
33+
console.warn('Notification id is required to be closed!');
34+
return;
35+
}
36+
const { notifications } = inAppNotificationsStore.getLatestValue();
37+
inAppNotificationsStore.partialNext({
38+
notifications: notifications.filter((notification) => notification.id !== id),
39+
});
40+
};

package/src/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './in-app-notifications-store';

0 commit comments

Comments
 (0)