diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 55a6744b..0a266a6b 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -503,11 +503,11 @@ const styles = StyleSheet.create({ markerOuterRingPulseWeb: Platform.OS === 'web' ? { - // @ts-ignore — web-only CSS animation properties - animationName: 'pulse-ring', - animationDuration: '2s', - animationIterationCount: 'infinite', - animationTimingFunction: 'ease-in-out', - } + // @ts-ignore — web-only CSS animation properties + animationName: 'pulse-ring', + animationDuration: '2s', + animationIterationCount: 'infinite', + animationTimingFunction: 'ease-in-out', + } : ({} as any), }); diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 2cea6d80..bc2d7563 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -5,7 +5,6 @@ import { useColorScheme } from 'nativewind'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; -import WebView from 'react-native-webview'; import { Loading } from '@/components/common/loading'; import ZeroState from '@/components/common/zero-state'; @@ -16,6 +15,7 @@ import { Box } from '@/components/ui/box'; import { Button, ButtonIcon, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; +import { HtmlRenderer } from '@/components/ui/html-renderer'; import { SharedTabs, type TabItem } from '@/components/ui/shared-tabs'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; @@ -145,17 +145,26 @@ export default function CallDetail() { useEffect(() => { if (call) { + // Try Latitude/Longitude first, but validate they are real coordinates if (call.Latitude && call.Longitude) { - setCoordinates({ - latitude: parseFloat(call.Latitude), - longitude: parseFloat(call.Longitude), - }); - } else if (call.Geolocation) { - const [lat, lng] = call.Geolocation.split(','); - setCoordinates({ - latitude: parseFloat(lat), - longitude: parseFloat(lng), - }); + const lat = parseFloat(call.Latitude); + const lng = parseFloat(call.Longitude); + if (!isNaN(lat) && !isNaN(lng) && (lat !== 0 || lng !== 0)) { + setCoordinates({ latitude: lat, longitude: lng }); + return; + } + } + + // Fall through to Geolocation if Latitude/Longitude are missing or invalid + if (call.Geolocation) { + const parts = call.Geolocation.split(','); + if (parts.length === 2) { + const lat = parseFloat(parts[0].trim()); + const lng = parseFloat(parts[1].trim()); + if (!isNaN(lat) && !isNaN(lng)) { + setCoordinates({ latitude: lat, longitude: lng }); + } + } } } }, [call]); @@ -186,7 +195,7 @@ export default function CallDetail() { * Opens the device's native maps application with directions to the call location */ const handleRoute = async () => { - if (!coordinates.latitude || !coordinates.longitude) { + if (coordinates.latitude === null || coordinates.longitude === null) { showToast('error', t('call_detail.no_location_for_routing')); return; } @@ -300,37 +309,7 @@ export default function CallDetail() { {t('call_detail.note')} - - - - - - - ${call.Note} - - `, - }} - androidLayerType="software" - /> + @@ -377,37 +356,7 @@ export default function CallDetail() { {protocol.Name} {protocol.Description} - - - - - - - ${protocol.ProtocolText} - - `, - }} - androidLayerType="software" - /> + ))} @@ -506,45 +455,17 @@ export default function CallDetail() { - - - - - - - ${call.Nature} - - `, - }} - androidLayerType="software" - /> + - {/* Map */} - - {coordinates.latitude && coordinates.longitude ? : null} - + {/* Map - only show when valid coordinates exist */} + {coordinates.latitude !== null && coordinates.longitude !== null ? ( + + + + ) : null} {/* Action Buttons */} diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index 522dc596..014cf8db 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -9,6 +9,12 @@ jest.mock('react-native', () => ({ ScrollView: ({ children, ...props }: any) =>
{children}
, StyleSheet: { create: (styles: any) => styles, + flatten: (styles: any) => { + if (Array.isArray(styles)) { + return Object.assign({}, ...styles.filter(Boolean)); + } + return styles || {}; + }, }, useWindowDimensions: () => ({ width: 375, height: 812 }), View: ({ children, ...props }: any) =>
{children}
, @@ -273,10 +279,10 @@ jest.mock('@/lib/navigation', () => ({ openMapsWithDirections: jest.fn().mockResolvedValue(true), })); -// Mock WebView -jest.mock('react-native-webview', () => ({ +// Mock HtmlRenderer +jest.mock('@/components/ui/html-renderer', () => ({ __esModule: true, - default: () =>
WebView
, + HtmlRenderer: () =>
HtmlRenderer
, })); // Mock date-fns @@ -317,7 +323,7 @@ describe('CallDetail', () => { beforeEach(() => { jest.clearAllMocks(); - + // Setup stores as selector-based stores useCallDetailStore.mockImplementation((selector: any) => { if (selector) { @@ -325,37 +331,37 @@ describe('CallDetail', () => { } return mockCallDetailStore; }); - + useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore); - + useCoreStore.mockImplementation((selector: any) => { if (selector) { return selector(mockCoreStore); } return mockCoreStore; }); - + useLocationStore.mockImplementation((selector: any) => { if (selector) { return selector(mockLocationStore); } return mockLocationStore; }); - + useStatusBottomSheetStore.mockImplementation((selector: any) => { if (selector) { return selector(mockStatusBottomSheetStore); } return mockStatusBottomSheetStore; }); - + useToastStore.mockImplementation((selector: any) => { if (selector) { return selector(mockToastStore); } return mockToastStore; }); - + // Setup securityStore as a selector-based store securityStore.mockImplementation((selector: any) => { const state = { diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index 8e311d77..2e6cc00b 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -197,10 +197,10 @@ jest.mock('date-fns', () => ({ format: jest.fn(() => '2024-01-01 12:00'), })); -// Mock react-native-webview -jest.mock('react-native-webview', () => ({ +// Mock HtmlRenderer +jest.mock('@/components/ui/html-renderer', () => ({ __esModule: true, - default: 'WebView', + HtmlRenderer: 'HtmlRenderer', })); jest.mock('@/hooks/use-analytics', () => ({ diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index dd3dd5e8..d1d4dce0 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -1,10 +1,10 @@ import { AlertTriangle, MapPin, Phone } from 'lucide-react-native'; import React from 'react'; import { StyleSheet } from 'react-native'; -import WebView from 'react-native-webview'; import { Box } from '@/components/ui/box'; import { HStack } from '@/components/ui/hstack'; +import { HtmlRenderer } from '@/components/ui/html-renderer'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; @@ -101,37 +101,7 @@ export const CallCard: React.FC = ({ call, priority }) => { {/* Nature of Call */} {call.Nature && ( - - - - - - - ${call.Nature} - - `, - }} - androidLayerType="software" - /> + )} diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx index 43ef5ff1..f140d0c7 100644 --- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx +++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx @@ -4,12 +4,12 @@ import React from 'react'; import { ContactDetailsSheet } from '../contact-details-sheet'; import { ContactType, type ContactResultData } from '@/models/v4/contacts/contactResultData'; -// Mock react-native-webview -jest.mock('react-native-webview', () => { +// Mock HtmlRenderer +jest.mock('@/components/ui/html-renderer', () => { const { View } = require('react-native'); return { __esModule: true, - default: (props: any) => , + HtmlRenderer: (props: any) => , }; }); diff --git a/src/components/contacts/__tests__/contact-notes-list.test.tsx b/src/components/contacts/__tests__/contact-notes-list.test.tsx index aa895678..fb02c792 100644 --- a/src/components/contacts/__tests__/contact-notes-list.test.tsx +++ b/src/components/contacts/__tests__/contact-notes-list.test.tsx @@ -13,25 +13,22 @@ jest.mock('react-i18next', () => ({ }), })); -// Mock react-native-webview -jest.mock('react-native-webview', () => { +// Mock HtmlRenderer +jest.mock('@/components/ui/html-renderer', () => { const React = require('react'); const { View, Text } = require('react-native'); return { __esModule: true, - default: React.forwardRef((props: any, ref: any) => { - // Extract HTML content from source.html for testing - const htmlContent = props.source?.html || ''; + HtmlRenderer: (props: any) => { + const htmlContent = props.html || ''; // Simple extraction of text content from HTML (removing tags) const textContent = htmlContent.replace(/<[^>]*>/g, '').trim(); return React.createElement(View, { - ...props, - ref, - testID: props.testID || 'webview', + testID: 'webview', }, React.createElement(Text, { testID: 'webview-content' }, textContent)); - }), + }, }; }); diff --git a/src/components/contacts/contact-notes-list.tsx b/src/components/contacts/contact-notes-list.tsx index fcfc3572..ed49df11 100644 --- a/src/components/contacts/contact-notes-list.tsx +++ b/src/components/contacts/contact-notes-list.tsx @@ -3,8 +3,8 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Linking, ScrollView, StyleSheet } from 'react-native'; -import { WebView } from 'react-native-webview'; +import { HtmlRenderer } from '@/components/ui/html-renderer'; import { useAnalytics } from '@/hooks/use-analytics'; import { type ContactNoteResultData } from '@/models/v4/contacts/contactNoteResultData'; import { useContactsStore } from '@/stores/contacts/store'; @@ -31,8 +31,6 @@ const ContactNoteCard: React.FC = ({ note }) => { const isInternal = note.Visibility === 0; const { colorScheme } = useColorScheme(); - const textColor = colorScheme === 'dark' ? '#FFFFFF' : '#000000'; - const backgroundColor = colorScheme === 'dark' ? '#374151' : '#F9FAFB'; const formatDate = (dateString: string) => { try { @@ -83,120 +81,73 @@ const ContactNoteCard: React.FC = ({ note }) => { {noteContent} ) : ( - { - // Allow initial load of our HTML content - if (request.url.startsWith('about:') || request.url.startsWith('data:')) { - return true; + onLinkPress={(url) => Linking.openURL(url)} + customCSS={` + html, body { + width: 100%; + height: auto; + min-height: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + box-sizing: border-box; } - - // For any external links, open in system browser instead - Linking.openURL(request.url); - return false; - }} - onNavigationStateChange={(navState) => { - // Additional protection: if navigation occurs to external URL, open in system browser - if (navState.url && !navState.url.startsWith('about:') && !navState.url.startsWith('data:')) { - Linking.openURL(navState.url); + p, div, span { + margin: 0 0 12px 0; + } + p:last-child, div:last-child { + margin-bottom: 0; + } + img { + max-width: 100%; + height: auto; + } + a { + color: ${colorScheme === 'dark' ? '#60A5FA' : '#3B82F6'}; + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + ul, ol { + padding-left: 20px; + margin: 12px 0; + } + li { + margin: 4px 0; + } + blockquote { + border-left: 4px solid ${colorScheme === 'dark' ? '#60A5FA' : '#3B82F6'}; + margin: 12px 0; + padding-left: 16px; + font-style: italic; + } + pre, code { + background-color: ${colorScheme === 'dark' ? '#1F2937' : '#F3F4F6'}; + padding: 8px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 14px; + } + table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + } + th, td { + border: 1px solid ${colorScheme === 'dark' ? '#374151' : '#E5E7EB'}; + padding: 8px; + text-align: left; + } + th { + background-color: ${colorScheme === 'dark' ? '#1F2937' : '#F9FAFB'}; + font-weight: bold; } - }} - source={{ - html: ` - - - - - - - ${noteContent} - - `, - }} + `} /> )} diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index f6faf427..689bdba5 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -54,6 +54,10 @@ interface MapViewProps { rotateEnabled?: boolean; scrollEnabled?: boolean; pitchEnabled?: boolean; + /** Initial center [lng, lat] passed to the map constructor so it starts at the right place */ + initialCenter?: [number, number]; + /** Initial zoom level passed to the map constructor */ + initialZoom?: number; } // MapView component @@ -73,68 +77,222 @@ export const MapView = forwardRef( rotateEnabled = true, scrollEnabled = true, pitchEnabled = true, + initialCenter, + initialZoom, }, ref ) => { const mapContainer = useRef(null); const map = useRef(null); const [isLoaded, setIsLoaded] = useState(false); + const [hasSize, setHasSize] = useState(false); useImperativeHandle(ref, () => ({ getMap: () => map.current, })); + // Wait until the container has non-zero dimensions before initializing mapbox-gl. + // Mapbox crashes with "null is not an object (evaluating 'r[3]')" in its + // projection-matrix code when the container has 0×0 size. useEffect(() => { - if (map.current || !mapContainer.current) return; - - const newMap = new mapboxgl.Map({ - container: mapContainer.current, - style: styleURL, - center: [-98.5795, 39.8283], // Default US center - zoom: 4, - attributionControl: attributionEnabled, - logoPosition: logoEnabled ? 'bottom-left' : undefined, - dragRotate: rotateEnabled, - scrollZoom: zoomEnabled, - dragPan: scrollEnabled, - pitchWithRotate: pitchEnabled, + const el = mapContainer.current; + if (!el) return; + + const check = () => { + if (el.clientWidth > 0 && el.clientHeight > 0) { + setHasSize(true); + return true; + } + return false; + }; + + // Already has size (common path) + if (check()) return; + + // Watch for layout via ResizeObserver + const ro = new ResizeObserver(() => { + if (check()) { + ro.disconnect(); + } }); + ro.observe(el); + return () => ro.disconnect(); + }, []); - if (!logoEnabled) { - // Hide logo via CSS if not enabled - newMap.on('load', () => { - const logoEl = mapContainer.current?.querySelector('.mapboxgl-ctrl-logo'); - if (logoEl) { - (logoEl as HTMLElement).style.display = 'none'; - } + useEffect(() => { + if (!hasSize || map.current || !mapContainer.current) return; + + // Double-check the container actually has layout dimensions. + // mapbox-gl's projection matrix code will throw if the canvas is 0×0. + const { clientWidth, clientHeight } = mapContainer.current; + if (clientWidth === 0 || clientHeight === 0) return; + + try { + // Use initialCenter/initialZoom if provided so the map starts at the + // correct position without needing a programmatic camera move later. + const startCenter = initialCenter && isFinite(initialCenter[0]) && isFinite(initialCenter[1]) ? initialCenter : ([-98.5795, 39.8283] as [number, number]); // Default US center + const startZoom = initialZoom != null && isFinite(initialZoom) ? initialZoom : 4; + + const newMap = new mapboxgl.Map({ + container: mapContainer.current, + style: styleURL, + center: startCenter, + zoom: startZoom, + attributionControl: attributionEnabled, + logoPosition: logoEnabled ? 'bottom-left' : undefined, + dragRotate: rotateEnabled, + scrollZoom: zoomEnabled, + dragPan: scrollEnabled, + pitchWithRotate: pitchEnabled, }); - } - if (compassEnabled) { - newMap.addControl(new mapboxgl.NavigationControl({ showCompass: true, showZoom: false }), 'top-right'); - } + if (!logoEnabled) { + // Hide logo via CSS if not enabled + newMap.on('load', () => { + const logoEl = mapContainer.current?.querySelector('.mapboxgl-ctrl-logo'); + if (logoEl) { + (logoEl as HTMLElement).style.display = 'none'; + } + }); + } - newMap.on('load', () => { - setIsLoaded(true); - onDidFinishLoadingMap?.(); - }); + if (compassEnabled) { + newMap.addControl(new mapboxgl.NavigationControl({ showCompass: true, showZoom: false }), 'top-right'); + } - newMap.on('moveend', (e: any) => { - // mapbox-gl propagates eventData from easeTo/flyTo into the event object. - // We tag all programmatic camera moves with { _programmatic: true } so the - // moveend handler can distinguish them from real user interactions. - const wasUser = !e._programmatic; - onCameraChanged?.({ properties: { isUserInteraction: wasUser } }); - }); + newMap.on('load', () => { + setIsLoaded(true); + onDidFinishLoadingMap?.(); + }); - map.current = newMap; + newMap.on('moveend', (e: any) => { + // mapbox-gl propagates eventData from easeTo/flyTo into the event object. + // We tag all programmatic camera moves with { _programmatic: true } so the + // moveend handler can distinguish them from real user interactions. + const wasUser = !e._programmatic; + onCameraChanged?.({ properties: { isUserInteraction: wasUser } }); + }); + + map.current = newMap; + + // Patch unproject to gracefully handle NaN results. + // mapbox-gl's internal mouse event handlers (mouseout, mousemove, etc.) + // call map.unproject() which throws "Invalid LngLat object: (NaN, NaN)" + // when the canvas/transform is in an invalid state (zero-size, mid-resize). + // These DOM events fire synchronously and can't be caught by error events + // or the _render patch below. + const origUnproject = newMap.unproject.bind(newMap); + newMap.unproject = (point: unknown) => { + try { + return origUnproject(point); + } catch { + // Return a safe fallback LngLat (0,0) instead of crashing + return new mapboxgl.LngLat(0, 0); + } + }; + + // Patch easeTo / flyTo to catch "Invalid LngLat object: (NaN, NaN)" + // errors that occur when resetNorth or other compass interactions read + // a corrupted transform center (e.g. after resize or animation race). + const origEaseTo = newMap.easeTo.bind(newMap); + newMap.easeTo = function (options: any, eventData?: any) { + try { + return origEaseTo(options, eventData); + } catch (e: any) { + if (e?.message?.includes('Invalid LngLat')) { + return this; + } + throw e; + } + }; + + const origFlyTo = newMap.flyTo.bind(newMap); + newMap.flyTo = function (options: any, eventData?: any) { + try { + return origFlyTo(options, eventData); + } catch (e: any) { + if (e?.message?.includes('Invalid LngLat')) { + return this; + } + throw e; + } + }; + + // Patch the internal _render method to gracefully handle zero-size + // containers. mapbox-gl v3 crashes in _calcMatrices → fromInvProjectionMatrix + // ("null is not an object evaluating r[3]") when the canvas has 0×0 + // dimensions (e.g. during route transitions or before layout completes). + const origRender = newMap._render; + if (typeof origRender === 'function') { + newMap._render = function (...args: unknown[]) { + try { + // eslint-disable-next-line react/no-this-in-sfc + const canvas = this.getCanvas?.(); + if (canvas && (canvas.width === 0 || canvas.height === 0)) { + return this; // skip frame when canvas is zero-sized + } + return origRender.apply(this, args); + } catch { + // Suppress projection-matrix errors from zero-size containers + return this; + } + }; + } + + // Suppress non-fatal mapbox-gl error events (e.g. "Invalid LngLat object: (NaN, NaN)") + // that occur when mouse events fire while the map canvas is resizing. + newMap.on('error', (e: { error?: Error }) => { + const msg = e.error?.message ?? ''; + if (msg.includes('Invalid LngLat')) { + return; + } + console.warn('[MapView.web] mapbox-gl error:', e.error); + }); + } catch (e) { + // mapbox-gl can throw during initialization if the container is not + // properly laid out (e.g. zero-size canvas). We silently ignore this; + // the map will simply not render rather than crash the app. + console.warn('[MapView.web] Failed to initialize mapbox-gl:', e); + } return () => { - map.current?.remove(); - map.current = null; + // Mark the map as removed so child components can detect it + if (map.current) { + (map.current as any).__removed = true; + map.current.remove(); + map.current = null; + } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [hasSize]); + + // Keep the map canvas in sync with container size changes. + // Also do an immediate resize so the canvas matches the container + // before any mouse events can fire (prevents NaN unproject errors). + useEffect(() => { + if (!map.current || !mapContainer.current) return; + + // Only resize when the container has non-zero dimensions. + // Calling resize() with a zero-size container sets invalid dimensions + // on mapbox's internal transform, causing _calcMatrices to crash. + const safeResize = () => { + const el = mapContainer.current; + if (el && el.clientWidth > 0 && el.clientHeight > 0) { + try { + map.current?.resize(); + } catch { + // ignore resize errors during teardown + } + } + }; + + // Immediate resize to sync canvas with current container size + safeResize(); + + const ro = new ResizeObserver(() => safeResize()); + ro.observe(mapContainer.current); + return () => ro.disconnect(); + }, [isLoaded]); // Update style when it changes useEffect(() => { @@ -144,7 +302,18 @@ export const MapView = forwardRef( }, [styleURL]); return ( -
+
{isLoaded && {children}}
); @@ -169,14 +338,20 @@ interface CameraProps { } // Camera component -export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, followUserLocation, followZoomLevel }, ref) => { +export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel }, ref) => { const map = React.useContext(MapContext); const geolocateControl = useRef(null); + const hasInitialized = useRef(false); useImperativeHandle(ref, () => ({ setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => { if (!map) return; + // Validate coordinates before passing to mapbox + if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) { + return; + } + map.easeTo( { center: options.centerCoordinate, @@ -190,6 +365,12 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve }, flyTo: (options: any) => { if (!map) return; + + // Validate center if provided + if (options.center && Array.isArray(options.center) && (!isFinite(options.center[0]) || !isFinite(options.center[1]))) { + return; + } + map.flyTo(options, { _programmatic: true }); }, })); @@ -197,19 +378,34 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve useEffect(() => { if (!map) return; - if (centerCoordinate) { - map.easeTo( - { - center: centerCoordinate, - zoom: zoomLevel, - bearing: heading, - pitch: pitch, - duration: animationDuration, - }, - { _programmatic: true } - ); + if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) { + // Skip the first render — the MapView already initialized at the correct + // position via initialCenter/initialZoom, so no programmatic move needed. + if (!hasInitialized.current) { + hasInitialized.current = true; + return; + } + + // For subsequent coordinate/zoom changes, animate to the new position. + const cameraOptions = { + center: centerCoordinate as [number, number], + zoom: zoomLevel, + bearing: heading, + pitch: pitch, + duration: animationDuration, + }; + + try { + if (animationMode === 'flyTo') { + map.flyTo(cameraOptions, { _programmatic: true }); + } else { + map.easeTo(cameraOptions, { _programmatic: true }); + } + } catch { + // Suppress projection-matrix errors during resize/transition + } } - }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration]); + }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]); useEffect(() => { if (!map || !followUserLocation) return; @@ -238,7 +434,11 @@ export const Camera = forwardRef(({ centerCoordinate, zoomLeve clearTimeout(triggerTimeoutId); } if (geolocateControl.current) { - map.removeControl(geolocateControl.current); + try { + map.removeControl(geolocateControl.current); + } catch { + // map may already be destroyed during route transitions + } geolocateControl.current = null; } }; @@ -321,7 +521,7 @@ export const PointAnnotation: React.FC = ({ id, coordinate // Update coordinate when values actually change (by value, not reference) useEffect(() => { - if (markerRef.current && coordinate) { + if (markerRef.current && coordinate && coordinate.length === 2 && isFinite(coordinate[0]) && isFinite(coordinate[1])) { markerRef.current.setLngLat(coordinate); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -382,13 +582,21 @@ export const UserLocation: React.FC = ({ visible = true, show map.on('load', onMapLoad); return () => { - map.off('load', onMapLoad); - map.removeControl(geolocate); + try { + map.off('load', onMapLoad); + map.removeControl(geolocate); + } catch { + // map may already be destroyed during route transitions + } }; } return () => { - map.removeControl(geolocate); + try { + map.removeControl(geolocate); + } catch { + // map may already be destroyed during route transitions + } }; }, [map, visible, showsUserHeadingIndicator]); diff --git a/src/components/maps/static-map.tsx b/src/components/maps/static-map.tsx index 13a90374..c6b48620 100644 --- a/src/components/maps/static-map.tsx +++ b/src/components/maps/static-map.tsx @@ -1,7 +1,7 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet } from 'react-native'; +import { Platform, StyleSheet, View } from 'react-native'; import Mapbox from '@/components/maps/mapbox'; import { Box } from '@/components/ui/box'; @@ -29,19 +29,22 @@ const StaticMap: React.FC = ({ latitude, longitude, address, zoo if (!latitude || !longitude) { return ( - + {t('call_detail.no_location')} ); } return ( - - + + - {/* Marker for the location */} + {/* Marker pin for the location */} - + + + + {/* Show user location if requested */} @@ -67,6 +70,42 @@ const styles = StyleSheet.create({ map: { flex: 1, }, + markerContainer: { + alignItems: 'center', + justifyContent: 'center', + width: 30, + height: 40, + }, + markerPin: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#E53E3E', + borderWidth: 3, + borderColor: '#FFFFFF', + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + }, + android: { + elevation: 4, + }, + }), + }, + markerDot: { + width: 0, + height: 0, + borderLeftWidth: 6, + borderRightWidth: 6, + borderTopWidth: 8, + borderLeftColor: 'transparent', + borderRightColor: 'transparent', + borderTopColor: '#E53E3E', + marginTop: -2, + }, addressContainer: { position: 'absolute', bottom: 0, diff --git a/src/components/notes/__tests__/note-details-sheet.test.tsx b/src/components/notes/__tests__/note-details-sheet.test.tsx index 347aae4f..0683c807 100644 --- a/src/components/notes/__tests__/note-details-sheet.test.tsx +++ b/src/components/notes/__tests__/note-details-sheet.test.tsx @@ -3,12 +3,12 @@ import React from 'react'; import { NoteDetailsSheet } from '../note-details-sheet'; -// Mock react-native-webview -jest.mock('react-native-webview', () => { +// Mock HtmlRenderer +jest.mock('@/components/ui/html-renderer', () => { const { View } = require('react-native'); return { __esModule: true, - default: View, + HtmlRenderer: View, }; }); diff --git a/src/components/notes/note-details-sheet.tsx b/src/components/notes/note-details-sheet.tsx index 0c11144b..4d5a34de 100644 --- a/src/components/notes/note-details-sheet.tsx +++ b/src/components/notes/note-details-sheet.tsx @@ -1,10 +1,9 @@ import { Calendar, Tag, X } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet } from 'react-native'; -import WebView from 'react-native-webview'; +import { HtmlRenderer } from '@/components/ui/html-renderer'; import { useAnalytics } from '@/hooks/use-analytics'; import { formatDateForDisplay, parseDateISOString } from '@/lib/utils'; import { useNotesStore } from '@/stores/notes/store'; @@ -20,7 +19,6 @@ import { VStack } from '../ui/vstack'; export const NoteDetailsSheet: React.FC = () => { const { t } = useTranslation(); - const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const notes = useNotesStore((s) => s.notes); const selectedNoteId = useNotesStore((s) => s.selectedNoteId); @@ -45,8 +43,6 @@ export const NoteDetailsSheet: React.FC = () => { if (!selectedNote) return null; - const textColor = colorScheme === 'dark' ? '#E5E7EB' : '#1F2937'; // gray-200 : gray-800 - return ( @@ -66,40 +62,9 @@ export const NoteDetailsSheet: React.FC = () => { - {/* Note content in WebView */} + {/* Note content */} - - - - - - - ${selectedNote.Body} - - `, - }} - androidLayerType="software" - /> + diff --git a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx index fac68c20..792de21f 100644 --- a/src/components/protocols/__tests__/protocol-details-sheet.test.tsx +++ b/src/components/protocols/__tests__/protocol-details-sheet.test.tsx @@ -25,13 +25,38 @@ jest.mock('nativewind', () => ({ cssInterop: jest.fn(), })); -jest.mock('react-native-webview', () => ({ +jest.mock('@/components/ui/html-renderer', () => ({ __esModule: true, - default: ({ source }: { source: any }) => { + HtmlRenderer: ({ html, ...rest }: { html: string;[key: string]: any }) => { const { View, Text } = require('react-native'); + // Mirror the real component's theme-aware defaults (light mode in test) + const textColor = rest.textColor || '#1F2937'; + const bgColor = rest.backgroundColor || '#F9FAFB'; return ( - {source.html} + {` + + + + + + + ${html} + + `} ); }, diff --git a/src/components/protocols/protocol-details-sheet.tsx b/src/components/protocols/protocol-details-sheet.tsx index d99978c3..0b34cacf 100644 --- a/src/components/protocols/protocol-details-sheet.tsx +++ b/src/components/protocols/protocol-details-sheet.tsx @@ -1,9 +1,8 @@ import { Calendar, Tag, X } from 'lucide-react-native'; -import { useColorScheme } from 'nativewind'; import React, { useEffect } from 'react'; import { StyleSheet } from 'react-native'; -import WebView from 'react-native-webview'; +import { HtmlRenderer } from '@/components/ui/html-renderer'; import { useAnalytics } from '@/hooks/use-analytics'; import { formatDateForDisplay, parseDateISOString, stripHtmlTags } from '@/lib/utils'; import { useProtocolsStore } from '@/stores/protocols/store'; @@ -18,7 +17,6 @@ import { Text } from '../ui/text'; import { VStack } from '../ui/vstack'; export const ProtocolDetailsSheet: React.FC = () => { - const { colorScheme } = useColorScheme(); const { trackEvent } = useAnalytics(); const protocols = useProtocolsStore((s) => s.protocols); const selectedProtocolId = useProtocolsStore((s) => s.selectedProtocolId); @@ -50,8 +48,6 @@ export const ProtocolDetailsSheet: React.FC = () => { ); } - const textColor = colorScheme === 'dark' ? '#E5E7EB' : '#1F2937'; // gray-200 : gray-800 - return ( @@ -86,42 +82,12 @@ export const ProtocolDetailsSheet: React.FC = () => { )} - {/* Protocol content in WebView */} - - - - - - - - ${selectedProtocol.ProtocolText} - - `, - }} - androidLayerType="software" - /> - + {/* Protocol content */} + {selectedProtocol.ProtocolText && ( + + + + )} diff --git a/src/components/ui/box/index.web.tsx b/src/components/ui/box/index.web.tsx index aa0fa86c..f20566fc 100644 --- a/src/components/ui/box/index.web.tsx +++ b/src/components/ui/box/index.web.tsx @@ -1,12 +1,14 @@ import type { VariantProps } from '@gluestack-ui/nativewind-utils'; import React from 'react'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import { boxStyle } from './styles'; -type IBoxProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { className?: string }; +type IBoxProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { className?: string; style?: StyleProp }; -const Box = React.forwardRef(({ className, ...props }, ref) => { - return
; +const Box = React.forwardRef(({ className, style, ...props }, ref) => { + const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style; + return
; }); Box.displayName = 'Box'; diff --git a/src/components/ui/card/index.web.tsx b/src/components/ui/card/index.web.tsx index 3ffa137a..948ba6f2 100644 --- a/src/components/ui/card/index.web.tsx +++ b/src/components/ui/card/index.web.tsx @@ -1,12 +1,14 @@ import type { VariantProps } from '@gluestack-ui/nativewind-utils'; import React from 'react'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import { cardStyle } from './styles'; -type ICardProps = React.ComponentPropsWithoutRef<'div'> & VariantProps; +type ICardProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp }; -const Card = React.forwardRef(({ className, size = 'md', variant = 'elevated', ...props }, ref) => { - return
; +const Card = React.forwardRef(({ className, size = 'md', variant = 'elevated', style, ...props }, ref) => { + const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style; + return
; }); Card.displayName = 'Card'; diff --git a/src/components/ui/center/index.web.tsx b/src/components/ui/center/index.web.tsx index 0a010efb..b8c8786b 100644 --- a/src/components/ui/center/index.web.tsx +++ b/src/components/ui/center/index.web.tsx @@ -1,12 +1,14 @@ import type { VariantProps } from '@gluestack-ui/nativewind-utils'; import React from 'react'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import { centerStyle } from './styles'; -type ICenterProps = React.ComponentPropsWithoutRef<'div'> & VariantProps; +type ICenterProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp }; -const Center = React.forwardRef(({ className, ...props }, ref) => { - return
; +const Center = React.forwardRef(({ className, style, ...props }, ref) => { + const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style; + return
; }); Center.displayName = 'Center'; diff --git a/src/components/ui/hstack/index.web.tsx b/src/components/ui/hstack/index.web.tsx index 2f457f59..843f4526 100644 --- a/src/components/ui/hstack/index.web.tsx +++ b/src/components/ui/hstack/index.web.tsx @@ -1,12 +1,14 @@ import type { VariantProps } from '@gluestack-ui/nativewind-utils'; import React from 'react'; +import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import { hstackStyle } from './styles'; -type IHStackProps = React.ComponentPropsWithoutRef<'div'> & VariantProps; +type IHStackProps = React.ComponentPropsWithoutRef<'div'> & VariantProps & { style?: StyleProp }; -const HStack = React.forwardRef, IHStackProps>(({ className, space, reversed, ...props }, ref) => { - return
; +const HStack = React.forwardRef, IHStackProps>(({ className, space, reversed, style, ...props }, ref) => { + const flatStyle = Array.isArray(style) ? StyleSheet.flatten(style) : style; + return
; }); HStack.displayName = 'HStack'; diff --git a/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx b/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx new file mode 100644 index 00000000..8ba04fbe --- /dev/null +++ b/src/components/ui/html-renderer/__tests__/html-renderer.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +// Mock nativewind useColorScheme — default to light mode +let mockColorScheme = 'light'; +jest.mock('nativewind', () => ({ + useColorScheme: () => ({ colorScheme: mockColorScheme }), + cssInterop: jest.fn(), +})); + +// Mock react-native-webview before importing HtmlRenderer +jest.mock('react-native-webview', () => { + const { View, Text } = require('react-native'); + return { + __esModule: true, + default: ({ source, ...rest }: { source: { html: string };[key: string]: any }) => ( + + {source.html} + + ), + }; +}); + +import { HtmlRenderer } from '../index'; + +describe('HtmlRenderer (native)', () => { + beforeEach(() => { + mockColorScheme = 'light'; + }); + + it('should render WebView with the provided HTML content', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('

Hello World

'); + expect(htmlContent.props.children).toContain(''); + }); + + it('should apply custom textColor override', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('color: #FF0000'); + }); + + it('should apply custom backgroundColor override', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('background-color: #333333'); + }); + + describe('light mode defaults', () => { + it('should use light theme text color when not specified', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('color: #1F2937'); + }); + + it('should use light theme background color when not specified', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('background-color: transparent'); + }); + }); + + describe('dark mode defaults', () => { + beforeEach(() => { + mockColorScheme = 'dark'; + }); + + it('should use dark theme text color when not specified', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('color: #E5E7EB'); + }); + + it('should use dark theme background color when not specified', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('background-color: transparent'); + }); + + it('should allow overriding colors in dark mode', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('color: #FFFFFF'); + expect(htmlContent.props.children).toContain('background-color: #000000'); + }); + }); + + it('should render the full HTML structure including viewport meta', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain(''); + expect(htmlContent.props.children).toContain(''); + expect(htmlContent.props.children).toContain('
Test
'); + }); + + it('should include responsive CSS styles', () => { + render(); + + const htmlContent = screen.getByTestId('webview-html'); + expect(htmlContent.props.children).toContain('max-width: 100%'); + expect(htmlContent.props.children).toContain('font-family: system-ui, -apple-system, sans-serif'); + }); +}); diff --git a/src/components/ui/html-renderer/index.tsx b/src/components/ui/html-renderer/index.tsx new file mode 100644 index 00000000..62cccfe3 --- /dev/null +++ b/src/components/ui/html-renderer/index.tsx @@ -0,0 +1,128 @@ +import { useColorScheme } from 'nativewind'; +import React from 'react'; +import { Linking, type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; +import WebView from 'react-native-webview'; + +/** Light / dark theme color tokens used when no explicit override is provided */ +const THEME_COLORS = { + light: { text: '#1F2937', background: 'transparent' }, // gray-800 + dark: { text: '#E5E7EB', background: 'transparent' }, // gray-200 +} as const; + +export interface HtmlRendererProps { + /** The raw HTML string to render inside the body tag */ + html: string; + /** Optional inline style applied to the container */ + style?: StyleProp; + /** Whether scrolling is enabled (default: false) */ + scrollEnabled?: boolean; + /** Whether to show the vertical scroll indicator (default: false) */ + showsVerticalScrollIndicator?: boolean; + /** Text color applied to the body – defaults to a theme-aware gray */ + textColor?: string; + /** Background color applied to the body – defaults to a theme-aware gray */ + backgroundColor?: string; + /** Optional React key for forcing re-renders */ + rendererKey?: string; + /** Additional CSS injected inside the + + ${html} + + `; + + const handleLinkPress = onLinkPress ?? ((url: string) => Linking.openURL(url)); + + return ( + { + if (request.url.startsWith('about:') || request.url.startsWith('data:')) { + return true; + } + handleLinkPress(request.url); + return false; + }, + onNavigationStateChange: (navState: { url: string }) => { + if (navState.url && !navState.url.startsWith('about:') && !navState.url.startsWith('data:')) { + handleLinkPress(navState.url); + } + }, + } + : {})} + /> + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + backgroundColor: 'transparent', + }, +}); diff --git a/src/components/ui/html-renderer/index.web.tsx b/src/components/ui/html-renderer/index.web.tsx new file mode 100644 index 00000000..dd9cbd9e --- /dev/null +++ b/src/components/ui/html-renderer/index.web.tsx @@ -0,0 +1,163 @@ +import { useColorScheme } from 'nativewind'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { Linking, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; + +/** Light / dark theme color tokens used when no explicit override is provided */ +const THEME_COLORS = { + light: { text: '#1F2937', background: 'transparent' }, // gray-800 + dark: { text: '#E5E7EB', background: 'transparent' }, // gray-200 +} as const; + +export interface HtmlRendererProps { + /** The raw HTML string to render inside the body tag */ + html: string; + /** Optional inline style applied to the container */ + style?: StyleProp; + /** Whether scrolling is enabled (default: false) */ + scrollEnabled?: boolean; + /** Whether to show the vertical scroll indicator (default: false) */ + showsVerticalScrollIndicator?: boolean; + /** Text color applied to the body – defaults to a theme-aware gray */ + textColor?: string; + /** Background color applied to the body – defaults to a theme-aware gray */ + backgroundColor?: string; + /** Optional React key for forcing re-renders */ + rendererKey?: string; + /** Additional CSS injected inside the + + ${html}${linkScript} + + `, + [html, scrollEnabled, showsVerticalScrollIndicator, resolvedTextColor, resolvedBgColor, customCSS, linkScript] + ); + + // Listen for link-click messages from the iframe + const handleMessage = useCallback( + (event: MessageEvent) => { + // Validate message origin/source - only accept messages from our trusted iframe + if (event.data?.type === 'html-renderer-link' && event.data.url && event.source === iframeRef.current?.contentWindow) { + if (onLinkPress) { + onLinkPress(event.data.url); + } else { + Linking.openURL(event.data.url); + } + } + }, + [onLinkPress] + ); + + React.useEffect(() => { + if (!onLinkPress) return; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [onLinkPress, handleMessage]); + + const flatStyle = StyleSheet.flatten([styles.container, style]) as Record; + + // When onLinkPress is provided we need allow-scripts so the click-interceptor runs + const sandboxValue = onLinkPress ? 'allow-same-origin allow-scripts' : 'allow-same-origin'; + + const iframeStyle: React.CSSProperties = { + border: 'none', + width: '100%', + height: '100%', + backgroundColor: 'transparent', + colorScheme: 'normal', + }; + + return ( + +