From 83f0dc0c4c5700af8e2e20fa9dba4ab1458b41e5 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 20:58:43 -0700 Subject: [PATCH 1/3] RU-T50 Call notes and Inbox fix --- .../__tests__/call-notes-modal-new.test.tsx | 4 +- src/components/calls/call-notes-modal.tsx | 83 ++++---- .../notifications/NotificationDetail.tsx | 29 +-- .../notifications/NotificationInbox.tsx | 193 +++++++++++------- 4 files changed, 170 insertions(+), 139 deletions(-) diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx index ab584b7d..229729f0 100644 --- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx @@ -66,9 +66,9 @@ jest.mock('react-native-keyboard-controller', () => ({ const { View } = require('react-native'); return {children}; }, - KeyboardStickyView: ({ children }: any) => { + KeyboardAvoidingView: ({ children }: any) => { const { View } = require('react-native'); - return {children}; + return {children}; }, })); diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 33048baa..1cafcf44 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -3,7 +3,7 @@ import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FlatList, Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; -import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; @@ -106,44 +106,44 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { return ( - {/* Header */} - - {t('callNotes.title')} - - - - - - {/* Search Bar */} - - - - - - - - - - {/* Notes List */} - - {isNotesLoading ? ( - - ) : filteredNotes.length > 0 ? ( - item.CallNoteId} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={true} - keyboardShouldPersistTaps="handled" - /> - ) : ( - - )} - - - {/* Add Note Section - Sticks to keyboard */} - + + {/* Header */} + + {t('callNotes.title')} + + + + + + {/* Search Bar */} + + + + + + + + + + {/* Notes List */} + + {isNotesLoading ? ( + + ) : filteredNotes.length > 0 ? ( + item.CallNoteId} + contentContainerStyle={styles.listContent} + showsVerticalScrollIndicator={true} + keyboardShouldPersistTaps="handled" + /> + ) : ( + + )} + + + {/* Add Note Section */} {t('callNotes.addNoteLabel')} @@ -161,7 +161,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { - + ); @@ -175,6 +175,9 @@ const styles = StyleSheet.create({ containerDark: { backgroundColor: '#1F2937', }, + keyboardAvoiding: { + flex: 1, + }, header: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx index 254dff35..c18eb81f 100644 --- a/src/components/notifications/NotificationDetail.tsx +++ b/src/components/notifications/NotificationDetail.tsx @@ -1,7 +1,6 @@ -import { useNotifications } from '@novu/react-native'; import { ArrowLeft, Calendar, ExternalLink, Trash2 } from 'lucide-react-native'; import { colorScheme } from 'nativewind'; -import React, { useEffect } from 'react'; +import React from 'react'; import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, StyleSheet, Text, View } from 'react-native'; // Define the interface directly in this file @@ -29,30 +28,8 @@ const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => { - const { refetch } = useNotifications(); - const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current; - const fadeAnim = React.useRef(new Animated.Value(0)).current; - - useEffect(() => { - // Mark as read when opened - we'll just refetch to sync with server - if (!notification.read && notification.id) { - refetch(); - } - - // Animate in - Animated.parallel([ - Animated.timing(slideAnim, { - toValue: 0, - duration: 300, - useNativeDriver: true, - }), - Animated.timing(fadeAnim, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }), - ]).start(); - }, [notification, refetch, slideAnim, fadeAnim]); + const slideAnim = React.useRef(new Animated.Value(0)).current; + const fadeAnim = React.useRef(new Animated.Value(1)).current; const handleClose = () => { // Animate out diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 53f55a17..8485e0f3 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -24,6 +24,69 @@ interface NotificationInboxProps { onClose: () => void; } +interface NotificationRowProps { + notification: NotificationPayload; + unread: boolean; + isSelectionMode: boolean; + isSelected: boolean; + onPress: (notification: NotificationPayload) => void; + onLongPress: (notification: NotificationPayload) => void; + onNavigateToReference: (referenceType: string, referenceId: string) => void; +} + +const NotificationRow = React.memo( + ({ notification, unread, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }: NotificationRowProps) => { + const handlePress = React.useCallback(() => onPress(notification), [onPress, notification]); + const handleLongPress = React.useCallback(() => onLongPress(notification), [onLongPress, notification]); + const handleNavigate = React.useCallback( + () => notification.referenceType && notification.referenceId && onNavigateToReference(notification.referenceType, notification.referenceId), + [onNavigateToReference, notification.referenceType, notification.referenceId] + ); + + return ( + + {unread ? : null} + + {isSelectionMode ? ( + + {isSelected ? : } + + ) : null} + + + {notification.title} + + {new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()} + + + + {!isSelectionMode ? ( + notification.referenceType && notification.referenceId ? ( + + + + + ) : ( + + ) + ) : null} + + ); + }, + (prev, next) => + prev.notification.id === next.notification.id && + prev.notification.title === next.notification.title && + prev.notification.createdAt === next.notification.createdAt && + prev.notification.referenceType === next.notification.referenceType && + prev.notification.referenceId === next.notification.referenceId && + prev.unread === next.unread && + prev.isSelectionMode === next.isSelectionMode && + prev.isSelected === next.isSelected +); +NotificationRow.displayName = 'NotificationRow'; + export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { const activeUnitId = useCoreStore((state) => state.activeUnitId); const config = useCoreStore((state: any) => state.config); @@ -77,15 +140,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = } }, [isOpen, slideAnim, fadeAnim]); - const handleNotificationPress = (notification: NotificationPayload) => { - if (isSelectionMode) { - toggleNotificationSelection(notification.id); - } else { - setSelectedNotification(notification); - } - }; - - const toggleNotificationSelection = (notificationId: string) => { + const toggleNotificationSelection = React.useCallback((notificationId: string) => { setSelectedNotificationIds((prev) => { const newSet = new Set(prev); if (newSet.has(notificationId)) { @@ -95,7 +150,28 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = } return newSet; }); - }; + }, []); + + const handleNotificationPress = React.useCallback( + (notification: NotificationPayload) => { + if (isSelectionMode) { + toggleNotificationSelection(notification.id); + } else { + setSelectedNotification(notification); + } + }, + [isSelectionMode, toggleNotificationSelection] + ); + + const handleNotificationLongPress = React.useCallback((notification: NotificationPayload) => { + setIsSelectionMode((prevMode) => { + if (!prevMode) { + setSelectedNotificationIds(new Set([notification.id])); + return true; + } + return prevMode; + }); + }, []); const enterSelectionMode = () => { setIsSelectionMode(true); @@ -153,68 +229,43 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = [showToast, refetch] ); - const handleNavigateToReference = (referenceType: string, referenceId: string) => { - // TODO: Implement navigation based on reference type - console.log('Navigate to:', referenceType, referenceId); - onClose(); - }; - - const renderItem = ({ item }: { item: any }) => { - const notification: NotificationPayload = { - id: item.id, - title: item.subject, - body: item.body, - createdAt: item.createdAt, - read: item.read, - type: item.type, - referenceId: item.payload?.referenceId, - referenceType: item.payload?.referenceType, - metadata: item.payload?.metadata, - }; - - const isSelected = selectedNotificationIds.has(notification.id); - - return ( - handleNotificationPress(notification)} - onLongPress={() => { - if (!isSelectionMode) { - enterSelectionMode(); - toggleNotificationSelection(notification.id); - } - }} - style={[styles.notificationItem, !item.read ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]} - > - {!item.read ? : null} - - {isSelectionMode ? ( - - {isSelected ? : } - - ) : null} - - - {notification.title} - - {new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()} - - + const handleNavigateToReference = React.useCallback( + (referenceType: string, referenceId: string) => { + // TODO: Implement navigation based on reference type + console.log('Navigate to:', referenceType, referenceId); + onClose(); + }, + [onClose] + ); - {!isSelectionMode ? ( - notification.referenceType && notification.referenceId ? ( - - - - - ) : ( - - ) - ) : null} - - ); - }; + const renderItem = React.useCallback( + ({ item }: { item: any }) => { + const notification: NotificationPayload = { + id: item.id, + title: item.subject, + body: item.body, + createdAt: item.createdAt, + read: item.read, + type: item.type, + referenceId: item.payload?.referenceId, + referenceType: item.payload?.referenceType, + metadata: item.payload?.metadata, + }; + + return ( + + ); + }, + [isSelectionMode, selectedNotificationIds, handleNotificationPress, handleNotificationLongPress, handleNavigateToReference] + ); const renderFooter = () => { if (!hasMore) return null; From cdc8ec14c69065715a0046cf970620b84afbe199 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 21:24:41 -0700 Subject: [PATCH 2/3] RU-T50 PR#257 fixes --- src/components/calls/call-notes-modal.tsx | 11 ++----- .../notifications/NotificationInbox.tsx | 33 ++++++++++++------- .../__tests__/NotificationInbox.test.tsx | 28 ++++++++++++++++ src/types/notification.ts | 1 + 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 1cafcf44..350d1c35 100644 --- a/src/components/calls/call-notes-modal.tsx +++ b/src/components/calls/call-notes-modal.tsx @@ -26,6 +26,8 @@ interface CallNotesModalProps { callId: string; } +const keyExtractor = (item: { CallNoteId: string }) => item.CallNoteId; + const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); @@ -130,14 +132,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => { {isNotesLoading ? ( ) : filteredNotes.length > 0 ? ( - item.CallNoteId} - contentContainerStyle={styles.listContent} - showsVerticalScrollIndicator={true} - keyboardShouldPersistTaps="handled" - /> + ) : ( )} diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index 8485e0f3..fe10b61f 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -1,4 +1,5 @@ import { useNotifications } from '@novu/react-native'; +import { router } from 'expo-router'; import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from 'lucide-react-native'; import { colorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; @@ -10,6 +11,7 @@ import { Button } from '@/components/ui/button'; import { FlatList } from '@/components/ui/flat-list'; import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal'; import { Text } from '@/components/ui/text'; +import { logger } from '@/lib/logging'; import { useCoreStore } from '@/stores/app/core-store'; import { useToastStore } from '@/stores/toast/store'; import { type NotificationPayload } from '@/types/notification'; @@ -157,21 +159,24 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = if (isSelectionMode) { toggleNotificationSelection(notification.id); } else { + // Mark unread notifications as read via the Novu SDK; the SDK + // optimistically updates its cache, refreshing inbox state. + notification.markAsRead?.(); setSelectedNotification(notification); } }, [isSelectionMode, toggleNotificationSelection] ); - const handleNotificationLongPress = React.useCallback((notification: NotificationPayload) => { - setIsSelectionMode((prevMode) => { - if (!prevMode) { + const handleNotificationLongPress = React.useCallback( + (notification: NotificationPayload) => { + if (!isSelectionMode) { setSelectedNotificationIds(new Set([notification.id])); - return true; + setIsSelectionMode(true); } - return prevMode; - }); - }, []); + }, + [isSelectionMode] + ); const enterSelectionMode = () => { setIsSelectionMode(true); @@ -231,9 +236,12 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = const handleNavigateToReference = React.useCallback( (referenceType: string, referenceId: string) => { - // TODO: Implement navigation based on reference type - console.log('Navigate to:', referenceType, referenceId); - onClose(); + if (referenceType === 'call') { + router.push(`/call/${referenceId}`); + onClose(); + } else { + logger.info({ message: 'Notification reference navigation not supported for type', context: { referenceType, referenceId } }); + } }, [onClose] ); @@ -245,17 +253,18 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = title: item.subject, body: item.body, createdAt: item.createdAt, - read: item.read, + read: item.isRead, type: item.type, referenceId: item.payload?.referenceId, referenceType: item.payload?.referenceType, metadata: item.payload?.metadata, + markAsRead: !item.isRead && typeof item.read === 'function' ? () => item.read() : undefined, }; return ( { referenceType: 'call', metadata: {}, }, + markAsRead: jest.fn(), }, { id: '2', @@ -418,6 +420,32 @@ describe('NotificationInbox', () => { expect(queryByText('Notifications')).toBeNull(); }); + it('marks an unread notification as read when opening the detail', async () => { + const { getByTestId } = render( + + ); + + await act(async () => { + fireEvent.press(getByTestId('notification-1')); + }); + + expect((mockNotifications[0] as any).markAsRead).toHaveBeenCalledTimes(1); + }); + + it('does not invoke mark-as-read for an already-read notification', async () => { + const { getByTestId, queryByText } = render( + + ); + + await act(async () => { + fireEvent.press(getByTestId('notification-2')); + }); + + // Already-read notification has no markAsRead attached; detail still opens + expect((mockNotifications[0] as any).markAsRead).not.toHaveBeenCalled(); + expect(queryByText('Notification Detail')).toBeTruthy(); + }); + it('resets state when component closes', async () => { const { rerender, getByTestId, getByText } = render( diff --git a/src/types/notification.ts b/src/types/notification.ts index 0559b9ed..118bc7ab 100644 --- a/src/types/notification.ts +++ b/src/types/notification.ts @@ -8,6 +8,7 @@ export interface NotificationPayload { referenceId?: string; referenceType?: 'call' | 'message' | 'status' | 'note' | 'other'; metadata?: Record; + markAsRead?: () => void; } export interface NotificationDetailProps { From 80a9966237194912edf04505be853921f84d7202 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 25 Jul 2026 21:38:59 -0700 Subject: [PATCH 3/3] RU-T50 PR#257 fixes --- src/components/notifications/NotificationInbox.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index fe10b61f..20d34938 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -258,7 +258,16 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = referenceId: item.payload?.referenceId, referenceType: item.payload?.referenceType, metadata: item.payload?.metadata, - markAsRead: !item.isRead && typeof item.read === 'function' ? () => item.read() : undefined, + markAsRead: + !item.isRead && typeof item.read === 'function' + ? async () => { + try { + await item.read(); + } catch (error) { + logger.warn({ message: 'Failed to mark notification as read', context: { error } }); + } + } + : undefined, }; return (