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 ab584b7..20b6275 100644 --- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx +++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx @@ -42,16 +42,24 @@ jest.mock('lucide-react-native', () => { jest.mock('../../common/loading', () => ({ Loading: () => { const { View, Text } = require('react-native'); - return Loading...; + return ( + + Loading... + + ); }, })); -// Mock ZeroState component +// Mock ZeroState component jest.mock('../../common/zero-state', () => ({ __esModule: true, default: ({ heading }: { heading: string }) => { const { View, Text } = require('react-native'); - return {heading}; + return ( + + {heading} + + ); }, })); @@ -62,6 +70,10 @@ jest.mock('../../ui/focus-aware-status-bar', () => ({ // Mock react-native-keyboard-controller jest.mock('react-native-keyboard-controller', () => ({ + KeyboardProvider: ({ children }: any) => { + const { View } = require('react-native'); + return {children}; + }, KeyboardAwareScrollView: ({ children }: any) => { const { View } = require('react-native'); return {children}; @@ -76,7 +88,11 @@ jest.mock('react-native-keyboard-controller', () => ({ jest.mock('react-native-gesture-handler', () => ({ ScrollView: ({ children, testID, ...props }: any) => { const { ScrollView } = require('react-native'); - return {children}; + return ( + + {children} + + ); }, PanGestureHandler: ({ children }: any) => children, State: {}, @@ -119,11 +135,7 @@ jest.mock('../../ui/button', () => ({ const { TouchableOpacity } = require('react-native'); const isButtonDisabled = disabled || isDisabled; return ( - + {children} ); @@ -242,15 +254,19 @@ describe('CallNotesModal', () => { }, } as any); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); - mockUseAuthStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockAuthStore) : mockAuthStore); + mockUseAuthStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockAuthStore) : mockAuthStore)); }); it('renders correctly when open', () => { @@ -285,13 +301,17 @@ describe('CallNotesModal', () => { it('handles search input correctly', () => { const mockSearchNotes = jest.fn(() => [mockCallDetailStore.callNotes[0]]); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - searchNotes: mockSearchNotes, - }) : { - ...mockCallDetailStore, - searchNotes: mockSearchNotes, - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + searchNotes: mockSearchNotes, + }) + : { + ...mockCallDetailStore, + searchNotes: mockSearchNotes, + } + ); const { getByPlaceholderText, getByText, queryByText } = render(); @@ -304,13 +324,17 @@ describe('CallNotesModal', () => { }); it('shows loading state correctly', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - isNotesLoading: true, - }) : { - ...mockCallDetailStore, - isNotesLoading: true, - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + isNotesLoading: true, + }) + : { + ...mockCallDetailStore, + isNotesLoading: true, + } + ); const { getByTestId } = render(); @@ -318,15 +342,19 @@ describe('CallNotesModal', () => { }); it('shows zero state when no notes found', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - callNotes: [], - searchNotes: jest.fn(() => []), - }) : { - ...mockCallDetailStore, - callNotes: [], - searchNotes: jest.fn(() => []), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + callNotes: [], + searchNotes: jest.fn(() => []), + }) + : { + ...mockCallDetailStore, + callNotes: [], + searchNotes: jest.fn(() => []), + } + ); const { getByText } = render(); @@ -335,15 +363,19 @@ describe('CallNotesModal', () => { it('handles adding a new note', async () => { const mockAddNote = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); const { getByPlaceholderText, getByText } = render(); @@ -360,15 +392,19 @@ describe('CallNotesModal', () => { it('disables add button when note input is empty', () => { const mockAddNote = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); const { getByText } = render(); @@ -382,17 +418,21 @@ describe('CallNotesModal', () => { it('disables add button when loading', () => { const mockAddNote = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - addNote: mockAddNote, - isNotesLoading: true, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - addNote: mockAddNote, - isNotesLoading: true, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + addNote: mockAddNote, + isNotesLoading: true, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + addNote: mockAddNote, + isNotesLoading: true, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); const { getByText, getByPlaceholderText } = render(); @@ -417,15 +457,19 @@ describe('CallNotesModal', () => { it('clears note input after successful submission', async () => { const mockAddNote = jest.fn().mockResolvedValue(undefined); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); const { getByPlaceholderText, getByText } = render(); @@ -442,15 +486,19 @@ describe('CallNotesModal', () => { it('does not add empty note when only whitespace is entered', async () => { const mockAddNote = jest.fn(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }) : { - ...mockCallDetailStore, - addNote: mockAddNote, - searchNotes: jest.fn(() => mockCallDetailStore.callNotes), - }); + mockUseCallDetailStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + }) + : { + ...mockCallDetailStore, + addNote: mockAddNote, + searchNotes: jest.fn(() => mockCallDetailStore.callNotes), + } + ); const { getByPlaceholderText, getByText } = render(); @@ -464,11 +512,15 @@ describe('CallNotesModal', () => { }); it('handles missing user profile gracefully', () => { - mockUseAuthStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - profile: null, - }) : { - profile: null, - }); + mockUseAuthStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + profile: null, + }) + : { + profile: null, + } + ); const { getByText } = render(); diff --git a/src/components/calls/call-images-modal.tsx b/src/components/calls/call-images-modal.tsx index 2b845ea..dea9296 100644 --- a/src/components/calls/call-images-modal.tsx +++ b/src/components/calls/call-images-modal.tsx @@ -7,7 +7,7 @@ import { useColorScheme } from 'nativewind'; import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Keyboard, Modal, SafeAreaView, StyleSheet, TouchableOpacity, View } from 'react-native'; -import { KeyboardStickyView } from 'react-native-keyboard-controller'; +import { KeyboardProvider, KeyboardStickyView } from 'react-native-keyboard-controller'; import { launchCallImagePicker } from '@/components/calls/call-image-picker'; import { Loading } from '@/components/common/loading'; @@ -216,6 +216,14 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call setFullScreenImage({ source, name }); }, []); + const handleStartAddImage = useCallback(() => { + setIsAddingImage(true); + }, []); + + const handleCloseFullScreenImage = useCallback(() => { + setFullScreenImage(null); + }, []); + const getActiveImage = () => { if (!validImages || validImages.length === 0 || activeIndex < 0 || activeIndex >= validImages.length) return null; return validImages[activeIndex]; @@ -410,30 +418,32 @@ const CallImagesModal: React.FC = ({ isOpen, onClose, call return ( <> - - {/* Header */} - - {isAddingImage ? t('callImages.add_new') : t('callImages.title')} - - {!isAddingImage && !isLoadingImages ? ( - - ) : null} - - - - - + + + {/* Header */} + + {isAddingImage ? t('callImages.add_new') : t('callImages.title')} + + {!isAddingImage && !isLoadingImages ? ( + + ) : null} + + + + + - {/* Content */} - {renderContent()} - + {/* Content */} + {renderContent()} + + {/* Full Screen Image Modal */} - setFullScreenImage(null)} imageSource={fullScreenImage?.source || { uri: '' }} imageName={fullScreenImage?.name} /> + ); }; diff --git a/src/components/calls/call-notes-modal.tsx b/src/components/calls/call-notes-modal.tsx index 33048ba..5c5ce8b 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 { KeyboardProvider, KeyboardStickyView } from 'react-native-keyboard-controller'; import { useAnalytics } from '@/hooks/use-analytics'; import { useAuthStore } from '@/lib/auth'; @@ -105,64 +105,66 @@ 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 */} - - - - {t('callNotes.addNoteLabel')} - - - - - - - + + + {/* 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 */} + + + + {t('callNotes.addNoteLabel')} + + + + + + + + + + + ); }; diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx index 254dff3..56e5004 100644 --- a/src/components/notifications/NotificationDetail.tsx +++ b/src/components/notifications/NotificationDetail.tsx @@ -32,14 +32,19 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate const { refetch } = useNotifications(); const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current; + const markedAsReadRef = React.useRef(null); useEffect(() => { - // Mark as read when opened - we'll just refetch to sync with server - if (!notification.read && notification.id) { + // Mark as read when opened - refetch once per notification to sync with server. + // Guarded by a ref so an unstable `refetch` reference cannot cause a refetch loop. + if (!notification.read && notification.id && markedAsReadRef.current !== notification.id) { + markedAsReadRef.current = notification.id; refetch(); } + }, [notification.id, notification.read, refetch]); - // Animate in + useEffect(() => { + // Animate in (slideAnim/fadeAnim are stable refs) Animated.parallel([ Animated.timing(slideAnim, { toValue: 0, @@ -52,7 +57,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate useNativeDriver: true, }), ]).start(); - }, [notification, refetch, slideAnim, fadeAnim]); + }, [slideAnim, fadeAnim]); const handleClose = () => { // Animate out diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index b1cfe14..3a10edd 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -25,6 +25,86 @@ interface NotificationInboxProps { onClose: () => void; } +interface NotificationRowProps { + item: any; + isSelectionMode: boolean; + isSelected: boolean; + onPress: (notification: NotificationPayload) => void; + onLongPress: (notificationId: string) => void; + onNavigateToReference: (referenceType: string, referenceId: string) => void; +} + +const parseNotificationDate = (createdAt: string | undefined | null): Date | null => { + if (!createdAt) return null; + const date = new Date(createdAt); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const formatNotificationDate = (createdAt: string | undefined | null): string => parseNotificationDate(createdAt)?.toLocaleDateString() ?? ''; + +const formatNotificationTime = (createdAt: string | undefined | null): string => parseNotificationDate(createdAt)?.toLocaleTimeString() ?? ''; + +const NotificationRow = React.memo(({ item, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }) => { + const notification: NotificationPayload = React.useMemo( + () => ({ + 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, + }), + [item] + ); + + const formattedDate = React.useMemo(() => formatNotificationDate(notification.createdAt), [notification.createdAt]); + const formattedTime = React.useMemo(() => formatNotificationTime(notification.createdAt), [notification.createdAt]); + + const handlePress = React.useCallback(() => onPress(notification), [onPress, notification]); + const handleLongPress = React.useCallback(() => onLongPress(notification.id), [onLongPress, notification.id]); + const handleNavigate = React.useCallback(() => { + if (notification.referenceType && notification.referenceId) { + onNavigateToReference(notification.referenceType, notification.referenceId); + } + }, [onNavigateToReference, notification.referenceType, notification.referenceId]); + + return ( + + {!item.read ? : null} + + {isSelectionMode ? ( + + {isSelected ? : } + + ) : null} + + + {notification.title} + + {formattedDate} {formattedTime} + + + + {!isSelectionMode ? ( + notification.referenceType && notification.referenceId ? ( + + + + + ) : ( + + ) + ) : null} + + ); +}); +NotificationRow.displayName = 'NotificationRow'; + export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { const userId = useAuthStore((state) => state.userId); const config = useCoreStore((state: any) => state.config); @@ -78,30 +158,43 @@ 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) => { - setSelectedNotificationIds((prev) => { - const newSet = new Set(prev); - if (newSet.has(notificationId)) { - newSet.delete(notificationId); + const handleNotificationPress = React.useCallback( + (notification: NotificationPayload) => { + if (isSelectionMode) { + setSelectedNotificationIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(notification.id)) { + newSet.delete(notification.id); + } else { + newSet.add(notification.id); + } + return newSet; + }); } else { - newSet.add(notificationId); + setSelectedNotification(notification); } - return newSet; - }); - }; + }, + [isSelectionMode] + ); + + const handleNotificationLongPress = React.useCallback( + (notificationId: string) => { + if (!isSelectionMode) { + setIsSelectionMode(true); + setSelectedNotificationIds((prev) => { + const newSet = new Set(prev); + newSet.add(notificationId); + return newSet; + }); + } + }, + [isSelectionMode] + ); - const enterSelectionMode = () => { + const enterSelectionMode = React.useCallback(() => { setIsSelectionMode(true); setSelectedNotificationIds(new Set()); - }; + }, []); const exitSelectionMode = React.useCallback(() => { setIsSelectionMode(false); @@ -154,70 +247,30 @@ 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 }) => ( + + ), + [isSelectionMode, selectedNotificationIds, handleNotificationPress, handleNotificationLongPress, handleNavigateToReference] + ); - const renderFooter = () => { + const renderFooter = React.useCallback(() => { if (!hasMore) return null; return ( @@ -225,12 +278,15 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ); - }; + }, [hasMore]); - const renderEmpty = () => ( - - No updates available - + const renderEmpty = React.useCallback( + () => ( + + No updates available + + ), + [] ); if (!isOpen) {