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..350d1c35 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';
@@ -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();
@@ -106,44 +108,37 @@ 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 ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Add Note Section */}
{t('callNotes.addNoteLabel')}
@@ -161,7 +156,7 @@ const CallNotesModal = ({ isOpen, onClose, callId }: CallNotesModalProps) => {
-
+
);
@@ -175,6 +170,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..20d34938 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';
@@ -24,6 +26,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 +142,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 +152,31 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
}
return newSet;
});
- };
+ }, []);
+
+ const handleNotificationPress = React.useCallback(
+ (notification: NotificationPayload) => {
+ 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) => {
+ if (!isSelectionMode) {
+ setSelectedNotificationIds(new Set([notification.id]));
+ setIsSelectionMode(true);
+ }
+ },
+ [isSelectionMode]
+ );
const enterSelectionMode = () => {
setIsSelectionMode(true);
@@ -153,68 +234,56 @@ 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) => {
+ if (referenceType === 'call') {
+ router.push(`/call/${referenceId}`);
+ onClose();
+ } else {
+ logger.info({ message: 'Notification reference navigation not supported for type', context: { referenceType, referenceId } });
+ }
+ },
+ [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.isRead,
+ type: item.type,
+ referenceId: item.payload?.referenceId,
+ referenceType: item.payload?.referenceType,
+ metadata: item.payload?.metadata,
+ 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 (
+
+ );
+ },
+ [isSelectionMode, selectedNotificationIds, handleNotificationPress, handleNotificationLongPress, handleNavigateToReference]
+ );
const renderFooter = () => {
if (!hasMore) return null;
diff --git a/src/components/notifications/__tests__/NotificationInbox.test.tsx b/src/components/notifications/__tests__/NotificationInbox.test.tsx
index 3e6bb341..36a3c742 100644
--- a/src/components/notifications/__tests__/NotificationInbox.test.tsx
+++ b/src/components/notifications/__tests__/NotificationInbox.test.tsx
@@ -61,6 +61,7 @@ const MockNotificationInbox = ({ isOpen, onClose }: { isOpen: boolean; onClose:
}
setSelectedNotificationIds(newSet);
} else {
+ notification.markAsRead?.();
setSelectedNotification(notification);
}
};
@@ -167,6 +168,7 @@ describe('NotificationInbox', () => {
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 {