diff --git a/package.json b/package.json
index 42682a7b..7843196c 100644
--- a/package.json
+++ b/package.json
@@ -151,6 +151,7 @@
"react-native-webview": "13.16.1",
"react-native-worklets": "0.8.3",
"react-query-kit": "3.3.2",
+ "sanitize-html": "2.17.0",
"tailwind-variants": "0.2.1",
"zod": "3.23.8",
"zustand": "4.5.7"
@@ -171,6 +172,7 @@
"@types/mapbox-gl": "3.4.1",
"@types/react": "~19.2.14",
"@types/react-native-base64": "0.2.2",
+ "@types/sanitize-html": "^2.16.0",
"@typescript-eslint/eslint-plugin": "8.56.0",
"@typescript-eslint/parser": "8.56.0",
"babel-jest": "30.0.5",
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 45410ba9..ff67bda0 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -426,9 +426,9 @@ export default function CallDetail() {
{callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? (
{callExtraData.Protocols.map((protocol, index) => (
-
+
{protocol.Name}
- {protocol.Description}
+ {protocol.Description}
diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
index 571a3860..a2b6ba2b 100644
--- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
+++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx
@@ -49,16 +49,31 @@ jest.mock('react-native-keyboard-controller', () => ({
},
}));
-// Mock lucide icons
-jest.mock('lucide-react-native', () => ({
- ChevronDown: () => null,
-}));
-
// Mock UI components
+jest.mock('@/components/ui/actionsheet', () => {
+ const { View } = require('react-native');
+ return {
+ Actionsheet: ({ isOpen, children, testID }: any) => (isOpen ? {children} : null),
+ ActionsheetBackdrop: ({ children }: any) => {children},
+ ActionsheetContent: ({ children, style }: any) => (
+
+ {children}
+
+ ),
+ ActionsheetDragIndicator: () => ,
+ ActionsheetDragIndicatorWrapper: ({ children }: any) => {children},
+ };
+});
+
jest.mock('@/components/ui/button', () => ({
- Button: ({ children, onPress, testID, disabled, ...props }: any) => {
+ Button: ({ children, onPress, testID, disabled, isDisabled, ...props }: any) => {
const { TouchableOpacity } = require('react-native');
- return {children};
+ const resolvedDisabled = disabled ?? isDisabled;
+ return (
+
+ {children}
+
+ );
},
ButtonText: ({ children, ...props }: any) => {
const { Text } = require('react-native');
@@ -66,6 +81,13 @@ jest.mock('@/components/ui/button', () => ({
},
}));
+jest.mock('@/components/ui/heading', () => ({
+ Heading: ({ children, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {children};
+ },
+}));
+
jest.mock('@/components/ui/text', () => ({
Text: ({ children, ...props }: any) => {
const { Text: RNText } = require('react-native');
@@ -87,6 +109,61 @@ jest.mock('@/components/ui/hstack', () => ({
},
}));
+jest.mock('@/components/ui/form-control', () => ({
+ FormControl: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ FormControlLabel: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ FormControlLabelText: ({ children, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {children};
+ },
+}));
+
+jest.mock('@/components/ui/select', () => ({
+ Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => {
+ const { View, TouchableOpacity, Text } = require('react-native');
+ return (
+
+ {children}
+ onValueChange && onValueChange('1')}>
+ Select Option
+
+
+ );
+ },
+ SelectTrigger: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectInput: ({ placeholder, ...props }: any) => {
+ const { Text } = require('react-native');
+ return {placeholder};
+ },
+ SelectIcon: () => null,
+ SelectPortal: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectBackdrop: () => null,
+ SelectContent: ({ children, ...props }: any) => {
+ const { View } = require('react-native');
+ return {children};
+ },
+ SelectItem: ({ label, value, ...props }: any) => {
+ const { View, Text } = require('react-native');
+ return (
+
+ {label}
+
+ );
+ },
+}));
+
jest.mock('@/components/ui/textarea', () => ({
Textarea: ({ children, ...props }: any) => {
const { View } = require('react-native');
@@ -117,12 +194,10 @@ const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction;
const mockUseToastStore = useToastStore as jest.MockedFunction;
-/** Helper: select a close call type via the inline dropdown */
+/** Helper: select a close call type via the gluestack Select */
function selectCloseCallType(type: string) {
const typeSelect = screen.getByTestId('close-call-type-select');
- fireEvent.press(typeSelect);
- const option = screen.getByTestId(`close-call-type-option-${type}`);
- fireEvent.press(option);
+ fireEvent(typeSelect, 'onValueChange', type);
}
describe('CloseCallBottomSheet', () => {
@@ -191,7 +266,7 @@ describe('CloseCallBottomSheet', () => {
const mockOnClose = jest.fn();
render();
- // Select close type via inline dropdown
+ // Select close type
selectCloseCallType('1');
// Add note
@@ -277,7 +352,7 @@ describe('CloseCallBottomSheet', () => {
render();
- // Select close type via inline dropdown
+ // Select close type
selectCloseCallType(type);
// Submit
diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx
index f2a887fe..db9b1edc 100644
--- a/src/components/calls/call-card.tsx
+++ b/src/components/calls/call-card.tsx
@@ -1,7 +1,7 @@
import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native';
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
-import { Animated, ScrollView, StyleSheet } from 'react-native';
+import { Animated, Platform, ScrollView, StyleSheet } from 'react-native';
import { Box } from '@/components/ui/box';
import { HStack } from '@/components/ui/hstack';
@@ -174,11 +174,14 @@ export const CallCard: React.FC = React.memo(({ call, priority, s
{/* Nature of Call */}
- {call.Nature && (
-
+ {call.Nature ? (
+ // Android's WebView claims the touch stream (requestDisallowInterceptTouchEvent),
+ // so a drag starting on it never reaches the surrounding list — kill its pointer
+ // events there and let the list scroll. iOS nests scrolling fine, leave it alone.
+
- )}
+ ) : null}
);
});
diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx
index a83b11e6..152ad372 100644
--- a/src/components/calls/close-call-bottom-sheet.tsx
+++ b/src/components/calls/close-call-bottom-sheet.tsx
@@ -1,16 +1,20 @@
import { useRouter } from 'expo-router';
-import { ChevronDown } from 'lucide-react-native';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Modal, Pressable as RNPressable, StyleSheet, View } from 'react-native';
+import { Platform } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Button, ButtonText } from '@/components/ui/button';
+import { FormControl, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control';
+import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
+import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select';
import { Text } from '@/components/ui/text';
import { Textarea, TextareaInput } from '@/components/ui/textarea';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
+import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
import { useCallDetailStore } from '@/stores/calls/detail-store';
import { useCallsStore } from '@/stores/calls/store';
import { useToastStore } from '@/stores/toast/store';
@@ -22,19 +26,10 @@ interface CloseCallBottomSheetProps {
isLoading?: boolean;
}
-const CLOSE_CALL_TYPES = [
- { value: '1', translationKey: 'call_detail.close_call_types.closed' },
- { value: '2', translationKey: 'call_detail.close_call_types.cancelled' },
- { value: '3', translationKey: 'call_detail.close_call_types.unfounded' },
- { value: '4', translationKey: 'call_detail.close_call_types.founded' },
- { value: '5', translationKey: 'call_detail.close_call_types.minor' },
- { value: '6', translationKey: 'call_detail.close_call_types.transferred' },
- { value: '7', translationKey: 'call_detail.close_call_types.false_alarm' },
-];
-
export const CloseCallBottomSheet: React.FC = ({ isOpen, onClose, callId, isLoading = false }) => {
const { t } = useTranslation();
const router = useRouter();
+ const keyboardHeight = useKeyboardHeight();
const showToast = useToastStore((state) => state.showToast);
const { trackEvent } = useAnalytics();
const closeCall = useCallDetailStore((state) => state.closeCall);
@@ -42,7 +37,6 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
const [closeCallType, setCloseCallType] = useState('');
const [closeCallNote, setCloseCallNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
- const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
// Track when close call bottom sheet is opened/rendered
React.useEffect(() => {
@@ -57,7 +51,6 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
const handleClose = React.useCallback(() => {
setCloseCallType('');
setCloseCallNote('');
- setIsTypeDropdownOpen(false);
onClose();
}, [onClose]);
@@ -94,46 +87,55 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
}
}, [closeCallType, showToast, t, callId, closeCallNote, handleClose, fetchCalls, router, closeCall]);
- const selectedTypeLabel = closeCallType ? t(CLOSE_CALL_TYPES.find((ct) => ct.value === closeCallType)?.translationKey ?? '') : t('call_detail.close_call_type_placeholder');
-
const isButtonDisabled = isLoading || isSubmitting;
return (
-
-
- e.stopPropagation()}>
-
-
-
-
- {t('call_detail.close_call')}
-
- {/* Close Call Type selector */}
-
- {t('call_detail.close_call_type')}
- setIsTypeDropdownOpen(!isTypeDropdownOpen)} testID="close-call-type-select">
- {selectedTypeLabel}
-
-
-
- {isTypeDropdownOpen && (
-
- {CLOSE_CALL_TYPES.map((type) => (
- {
- setCloseCallType(type.value);
- setIsTypeDropdownOpen(false);
- }}
- testID={`close-call-type-option-${type.value}`}
- >
- {t(type.translationKey)}
-
- ))}
-
- )}
-
+
+
+ {/* Same keyboard treatment as the other sheets: the sheet is bottom-anchored
+ and content-sized, so padding it by the keyboard height slides it up out
+ from under the keyboard, max-h caps it, and shrink lets the scrollview
+ compress and scroll instead of Yoga clipping the overflow. */}
+
+
+
+
+
+
+
+ {t('call_detail.close_call')}
+
+
+
+
+
+
+ {t('call_detail.close_call_type')}
+
+
+
{t('call_detail.close_call_note')}
@@ -142,93 +144,18 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
-
-
-
-
-
+
+
+
);
};
-
-const styles = StyleSheet.create({
- backdrop: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.4)',
- justifyContent: 'flex-end',
- },
- sheet: {
- backgroundColor: 'white',
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- maxHeight: '90%',
- paddingBottom: 34,
- paddingTop: 8,
- },
- handle: {
- width: 36,
- height: 4,
- borderRadius: 2,
- backgroundColor: '#D1D5DB',
- alignSelf: 'center',
- marginBottom: 8,
- },
- scrollView: {
- width: '100%',
- },
- scrollViewContent: {
- flexGrow: 1,
- paddingBottom: 40,
- },
- typeTrigger: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- borderWidth: 1,
- borderColor: '#D1D5DB',
- borderRadius: 8,
- paddingHorizontal: 12,
- paddingVertical: 12,
- backgroundColor: '#F9FAFB',
- },
- typeText: {
- fontSize: 16,
- color: '#111827',
- },
- typePlaceholder: {
- fontSize: 16,
- color: '#9CA3AF',
- },
- typeDropdown: {
- borderWidth: 1,
- borderColor: '#D1D5DB',
- borderRadius: 8,
- backgroundColor: 'white',
- marginTop: 4,
- },
- typeOption: {
- paddingHorizontal: 12,
- paddingVertical: 12,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: '#E5E7EB',
- },
- typeOptionSelected: {
- backgroundColor: '#EFF6FF',
- },
- typeOptionText: {
- fontSize: 15,
- color: '#374151',
- },
- typeOptionTextSelected: {
- fontSize: 15,
- color: '#2563EB',
- fontWeight: '600',
- },
-});
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index b9166953..d09cfc92 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { ScrollView, TouchableOpacity } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
import { logger } from '@/lib/logging';
import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils';
import { invertColor } from '@/lib/utils';
@@ -93,6 +94,7 @@ const getPreferredDestinationTab = ({
export const StatusBottomSheet = () => {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
+ const keyboardHeight = useKeyboardHeight();
const [selectedTab, setSelectedTab] = React.useState('call');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const showToast = useToastStore((state) => state.showToast);
@@ -618,12 +620,16 @@ export const StatusBottomSheet = () => {
return (
-
+ {/* The sheet renders inside a native Modal, so keyboard-controller's window-bound
+ avoidance never moves it — padding by the keyboard height reserves the covered
+ strip instead. shrink on the column lets the step content compress into the
+ remaining space so its scrollview actually scrolls rather than Yoga clipping it. */}
+
-
+
{t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()}
@@ -858,7 +864,13 @@ export const StatusBottomSheet = () => {
) : null}
{currentStep === 'add-note' ? (
-
+
{t('status.selected_status')}:
diff --git a/src/components/ui/html-renderer/index.tsx b/src/components/ui/html-renderer/index.tsx
index 62cccfe3..a0f71650 100644
--- a/src/components/ui/html-renderer/index.tsx
+++ b/src/components/ui/html-renderer/index.tsx
@@ -3,6 +3,8 @@ import React from 'react';
import { Linking, type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
import WebView from 'react-native-webview';
+import { sanitizeHtmlContent } from '@/utils/html-sanitizer';
+
/** Light / dark theme color tokens used when no explicit override is provided */
const THEME_COLORS = {
light: { text: '#1F2937', background: 'transparent' }, // gray-800
@@ -83,7 +85,7 @@ export const HtmlRenderer: React.FC = ({
${customCSS}
- ${html}
+ ${sanitizeHtmlContent(html)}