From 179691082e08116eae6c6988b107719c7b875068 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 21 Aug 2026 10:30:48 -0700 Subject: [PATCH] RU-T52 Map fix, selected call fix, softkeyboard fix, perf/stability fixes --- assets/mapping/direction_arrow.png | Bin 0 -> 616 bytes jest-setup.ts | 25 +- src/__tests__/no-self-mocking-suites.test.ts | 93 + src/api/calls/callFiles.ts | 3 +- src/api/calls/calls.ts | 17 +- src/api/common/__tests__/api-provider.test.ts | 30 + src/api/common/__tests__/client.test.ts | 15 +- src/api/common/api-provider.tsx | 10 +- src/api/common/client.tsx | 3 + src/api/notes/notes.ts | 4 +- src/app/(app)/__tests__/calls.test.tsx | 185 +- src/app/(app)/__tests__/index.test.tsx | 358 +++- .../__tests__/init-retry-backoff.test.tsx | 154 ++ src/app/(app)/_layout.tsx | 36 +- src/app/(app)/calls.tsx | 42 +- src/app/(app)/chat.tsx | 56 +- src/app/(app)/contacts.tsx | 13 +- src/app/(app)/index.tsx | 414 ++--- src/app/(app)/notes.tsx | 13 +- src/app/(app)/protocols.tsx | 13 +- src/app/(app)/weather-alerts.tsx | 2 +- src/app/_layout.tsx | 1 - src/app/call/[id].tsx | 14 +- src/app/call/[id]/edit.tsx | 469 ++--- src/app/call/__tests__/[id].test.tsx | 65 +- src/app/call/new/index.tsx | 584 +++--- src/app/chat/[channelId].tsx | 9 +- src/app/login/__tests__/login-form.test.tsx | 762 +++++--- src/app/login/login-form.tsx | 45 +- src/app/maps/indoor/[id].tsx | 111 +- src/app/routes/directions.tsx | 36 +- .../calls/__tests__/call-card.test.tsx | 77 + .../__tests__/call-images-modal.test.tsx | 1651 +++++++---------- .../dispatch-selection-basic.test.tsx | 148 +- .../dispatch-selection-modal.test.tsx | 31 +- .../full-screen-image-modal.test.tsx | 612 +++--- src/components/calls/call-card.tsx | 27 +- src/components/calls/call-files-modal.tsx | 13 +- src/components/calls/call-images-modal.tsx | 68 +- src/components/calls/call-notes-modal.tsx | 3 +- .../calls/close-call-bottom-sheet.tsx | 3 +- .../calls/dispatch-selection-modal.tsx | 285 +-- .../calls/full-screen-image-modal.tsx | 45 +- .../chat/__tests__/message-bubble.test.tsx | 138 ++ src/components/chat/message-bubble.tsx | 13 +- .../contacts/contact-details-sheet.tsx | 4 +- .../full-screen-location-picker.test.tsx | 602 +++++- .../maps/__tests__/map-pins.test.tsx | 141 ++ .../__tests__/unit-location-marker.test.tsx | 58 + .../maps/full-screen-location-picker.tsx | 83 +- src/components/maps/location-picker.tsx | 5 +- src/components/maps/map-pins.tsx | 37 +- src/components/maps/map-view.web.tsx | 516 ++++-- src/components/maps/pin-marker.tsx | 34 +- src/components/maps/unit-location-marker.tsx | 114 ++ .../notifications/NotificationButton.tsx | 4 +- .../notifications/NotificationInbox.tsx | 123 +- .../NotificationInbox.i18n-theme.test.tsx | 202 ++ src/components/routes/active-routes-list.tsx | 47 +- .../sidebar/__tests__/call-sidebar.test.tsx | 591 +++--- src/components/sidebar/call-sidebar.tsx | 139 +- src/components/sidebar/sidebar-content.tsx | 6 +- src/components/sidebar/unit-sidebar.tsx | 28 +- .../__tests__/status-bottom-sheet.test.tsx | 110 ++ src/components/status/status-bottom-sheet.tsx | 161 +- .../__tests__/use-quick-check-in.test.ts | 20 +- src/hooks/use-quick-check-in.ts | 7 +- src/lib/__tests__/map-camera.test.ts | 238 +++ src/lib/auth/__tests__/api-timeout.test.ts | 50 + src/lib/auth/__tests__/jwt.test.ts | 74 + src/lib/auth/api.tsx | 4 + src/lib/auth/index.tsx | 4 +- src/lib/auth/jwt.ts | 51 + src/lib/auth/types.tsx | 1 - .../hooks/__tests__/use-keep-alive.test.tsx | 122 ++ src/lib/hooks/use-keep-alive.tsx | 12 +- src/lib/map-camera.ts | 141 ++ .../__tests__/aptabase.service.test.ts | 82 + .../__tests__/bluetooth-audio.service.test.ts | 149 +- .../__tests__/countly.service.test.ts | 55 +- src/services/__tests__/location.test.ts | 64 +- .../offline-event-manager.service.test.ts | 153 +- .../signalr.service.reconnect-fix.test.ts | 91 +- .../__tests__/signalr.service.test.ts | 258 ++- src/services/analytics.service.ts | 5 + src/services/aptabase.service.ts | 5 + src/services/bluetooth-audio.service.ts | 84 +- src/services/location.ts | 65 +- src/services/offline-event-manager.service.ts | 52 +- src/services/signalr.service.ts | 27 +- src/stores/app/__tests__/core-store.test.ts | 75 +- .../livekit-store-room-switch.test.ts | 50 + src/stores/app/core-store.ts | 56 +- src/stores/app/livekit-store.ts | 16 + .../auth/__tests__/store-cold-start.test.ts | 361 ++++ src/stores/auth/store.tsx | 290 +-- src/stores/calls/__tests__/store.test.ts | 137 ++ src/stores/calls/store.ts | 43 +- .../chat/__tests__/hub-invoke-args.test.ts | 66 +- src/stores/chat/store.ts | 8 +- .../check-in-timers/__tests__/store.test.ts | 14 + src/stores/check-in-timers/store.ts | 4 +- .../feature-flags/__tests__/store.test.ts | 37 + src/stores/feature-flags/store.ts | 12 +- .../offline-queue/__tests__/store.test.ts | 163 +- src/stores/offline-queue/store.ts | 72 +- src/stores/security/__tests__/store.test.ts | 99 +- src/stores/security/store.ts | 22 +- .../signalr/__tests__/signalr-store.test.ts | 124 +- src/stores/signalr/signalr-store.ts | 94 +- src/translations/ar.json | 62 +- src/translations/de.json | 62 +- src/translations/el.json | 62 +- src/translations/en.json | 62 +- src/translations/es.json | 62 +- src/translations/fr.json | 62 +- src/translations/it.json | 62 +- src/translations/pl.json | 62 +- src/translations/sv.json | 62 +- src/translations/uk.json | 62 +- src/utils/__tests__/strip-html.test.ts | 56 + src/utils/strip-html.ts | 25 + theme-tokens.css | 17 - 123 files changed, 9942 insertions(+), 4034 deletions(-) create mode 100644 assets/mapping/direction_arrow.png create mode 100644 src/__tests__/no-self-mocking-suites.test.ts create mode 100644 src/api/common/__tests__/api-provider.test.ts create mode 100644 src/app/(app)/__tests__/init-retry-backoff.test.tsx create mode 100644 src/components/calls/__tests__/call-card.test.tsx create mode 100644 src/components/chat/__tests__/message-bubble.test.tsx create mode 100644 src/components/maps/__tests__/map-pins.test.tsx create mode 100644 src/components/maps/__tests__/unit-location-marker.test.tsx create mode 100644 src/components/maps/unit-location-marker.tsx create mode 100644 src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx create mode 100644 src/lib/__tests__/map-camera.test.ts create mode 100644 src/lib/auth/__tests__/api-timeout.test.ts create mode 100644 src/lib/auth/__tests__/jwt.test.ts create mode 100644 src/lib/auth/jwt.ts create mode 100644 src/lib/hooks/__tests__/use-keep-alive.test.tsx create mode 100644 src/lib/map-camera.ts create mode 100644 src/services/__tests__/aptabase.service.test.ts create mode 100644 src/stores/auth/__tests__/store-cold-start.test.ts create mode 100644 src/utils/__tests__/strip-html.test.ts create mode 100644 src/utils/strip-html.ts diff --git a/assets/mapping/direction_arrow.png b/assets/mapping/direction_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..3bc4210971ab29886a2a36357f4c0b5ed51ea5dd GIT binary patch literal 616 zcmV-u0+;=XP)sdSOZsti(vf{O+H=gl+ii6;vB&8JcS0Y#^O#?UNq8jT|fHP|Ja5<10d)=Ag80p5>@S#fh zn2dkIE5mO{p>N@9s`Ww8_VWb@k9)WPu(sw?0op3r$?b3@sPjbU6 zzjY?K2u&YaKT&D6Py0&?0BevEr$+(fZtG#RG`upbbsm#3?m}C`p08T~Sc}-eO-#nP zyVL4xhgXJcoW!!d+?soW$fj+@;znOkRe&R6BvmI=D-<6PT=vyHs0^ z$whIOYAZ3Bg1b~(iOE#lrP?Y?Ho;x0t-@qe+@;zIOfCy|oy6oaao0&qE(3RW&j+3B zr?EJ@9|<^i29r&27oM897$mPBlMruVa6BzW2x>5NCEn5=`lMcUltA>@rmrI`E|?wYi@$u^ud z9+hBn7Veha=4FjXC76tH7b;wJo12cu+zT-_y=jED7vaL6k}0JMv|UFgH;Pm6aqja} z`r;1L40X~C0#D@Cr*;;L#bU8oEEbE!VzF2(Q+)%&0sfw&3l2U20000 ({ })), })); -// Mock zod globally to avoid validation schema issues in tests -jest.mock('zod', () => ({ - z: { - object: jest.fn(() => ({ - parse: jest.fn((data) => data), - safeParse: jest.fn((data) => ({ success: true, data })), - })), - string: jest.fn(() => ({ - min: jest.fn(() => ({ - parse: jest.fn((data) => data), - safeParse: jest.fn((data) => ({ success: true, data })), - })), - parse: jest.fn((data) => data), - safeParse: jest.fn((data) => ({ success: true, data })), - })), - }, - __esModule: true, -})); +// zod is deliberately NOT mocked. A global stub used to live here, returning a +// schema whose safeParse accepted everything and which had no parseAsync at all +// — the method zodResolver actually calls. Any form suite that loaded it either +// crashed on `import * as z` or silently validated nothing. Every schema in this +// app guards a form a responder depends on (login, SSO, call create, call edit), +// so those schemas must run for real in tests. zod is plain CJS and resolves +// under jest with no configuration. diff --git a/src/__tests__/no-self-mocking-suites.test.ts b/src/__tests__/no-self-mocking-suites.test.ts new file mode 100644 index 00000000..98cc8077 --- /dev/null +++ b/src/__tests__/no-self-mocking-suites.test.ts @@ -0,0 +1,93 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Guards against test suites that mock away the very module they claim to cover. + * + * A file at `/__tests__/.test.tsx` whose subject is `/.tsx` + * must not call `jest.mock('../')`. Doing so replaces the subject with a + * hand-written stand-in, so the suite asserts against the stand-in and executes + * none of the production code — it passes forever, including when the real + * component is broken or deleted. + * + * Mocking a subject's *dependencies* is normal and correct; only the subject + * itself is off limits. That is why the check is narrow: it fires solely when a + * test mocks the sibling module it is named after. + * + * A sweep on 2026-08-21 found eight such suites. Four covered production code + * that nothing else touched (login-form, call-images-modal, + * full-screen-image-modal, full-screen-location-picker) and were rewritten + * against the real components — which immediately surfaced six real bugs. The + * four below still have a sibling suite exercising the real code, so they are + * misleading rather than dangerous. They are listed here as known debt: the list + * may shrink, never grow. + */ +const KNOWN_SELF_MOCKING_SUITES = [ + 'src/components/notifications/__tests__/NotificationInbox.test.tsx', + 'src/components/maps/__tests__/pin-detail-modal.test.tsx', + 'src/components/calls/__tests__/call-notes-modal.test.tsx', + 'src/components/calls/__tests__/call-detail-menu.test.tsx', +]; + +const SRC = path.join(__dirname, '..'); +const SUBJECT_EXTENSIONS = ['.ts', '.tsx']; + +const collectTestFiles = (dir: string, found: string[] = []): string[] => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + collectTestFiles(full, found); + } else if (/\.(test|spec)\.tsx?$/.test(entry.name)) { + found.push(full); + } + } + return found; +}; + +/** + * Strip comments so a suite that merely *describes* the anti-pattern in prose + * (as the rewritten login-form suite does) is not mistaken for one committing it. + */ +const stripComments = (source: string): string => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); + +/** Returns the repo-relative path of a suite that mocks its own subject, or null. */ +const findSelfMock = (testFile: string): string | null => { + const dir = path.dirname(testFile); + if (path.basename(dir) !== '__tests__') { + return null; + } + + const subjectName = path.basename(testFile).replace(/\.(test|spec)\.tsx?$/, ''); + const subjectDir = path.dirname(dir); + const subjectExists = SUBJECT_EXTENSIONS.some((ext) => fs.existsSync(path.join(subjectDir, `${subjectName}${ext}`))); + if (!subjectExists) { + return null; + } + + const source = stripComments(fs.readFileSync(testFile, 'utf8')); + // Match jest.mock('../') / jest.doMock("../"), with or without a factory. + const selfMock = new RegExp(String.raw`jest\.(?:do)?[Mm]ock\(\s*['"\`]\.\./${subjectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`); + return selfMock.test(source) ? path.relative(path.join(SRC, '..'), testFile) : null; +}; + +describe('test suites cover their real subject', () => { + const testFiles = collectTestFiles(SRC); + + it('finds test files to check', () => { + expect(testFiles.length).toBeGreaterThan(100); + }); + + it('has no self-mocking suite outside the known-debt list', () => { + const offenders = testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null); + const unexpected = offenders.filter((entry) => !KNOWN_SELF_MOCKING_SUITES.includes(entry)); + + expect(unexpected).toEqual([]); + }); + + it('keeps the known-debt list honest — entries that no longer self-mock must be removed', () => { + const offenders = new Set(testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null)); + const staleEntries = KNOWN_SELF_MOCKING_SUITES.filter((entry) => !offenders.has(entry)); + + expect(staleEntries).toEqual([]); + }); +}); diff --git a/src/api/calls/callFiles.ts b/src/api/calls/callFiles.ts index 8eb0d801..1a8ea0ce 100644 --- a/src/api/calls/callFiles.ts +++ b/src/api/calls/callFiles.ts @@ -2,6 +2,7 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp import { Platform } from 'react-native'; import { createApiEndpoint } from '@/api/common/client'; +import { logger } from '@/lib/logging'; import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult'; import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult'; @@ -83,7 +84,7 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption // Callers should check the return value and fall back to expo-file-system / expo-sharing on native. export const saveBlobAsFile = (blob: Blob, fileName: string): boolean => { if (Platform.OS !== 'web') { - console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.'); + logger.warn({ message: 'saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.' }); return false; } diff --git a/src/api/calls/calls.ts b/src/api/calls/calls.ts index 10da8082..23f611eb 100644 --- a/src/api/calls/calls.ts +++ b/src/api/calls/calls.ts @@ -1,4 +1,5 @@ import { cacheManager } from '@/lib/cache/cache-manager'; +import { logger } from '@/lib/logging'; import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult'; import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult'; import { type CallResult } from '@/models/v4/calls/callResult'; @@ -17,8 +18,8 @@ const createCallApi = createApiEndpoint('/Calls/SaveCall'); const updateCallApi = createApiEndpoint('/Calls/EditCall'); const closeCallApi = createApiEndpoint('/Calls/CloseCall'); -export const getCalls = async () => { - const response = await callsApi.get(); +export const getCalls = async (forceRefresh = false) => { + const response = await callsApi.get(undefined, { forceRefresh }); return response.data; }; @@ -136,8 +137,8 @@ export const createCall = async (callData: CreateCallRequest) => { try { cacheManager.remove('/Calls/GetActiveCalls'); } catch (error) { - // Silently handle cache removal errors - console.warn('Failed to invalidate calls cache:', error); + // Cache removal failures are non-fatal + logger.warn({ message: 'Failed to invalidate calls cache', context: { error } }); } return response.data; @@ -169,8 +170,8 @@ export const updateCall = async (callData: UpdateCallRequest) => { try { cacheManager.remove('/Calls/GetActiveCalls'); } catch (error) { - // Silently handle cache removal errors - console.warn('Failed to invalidate calls cache:', error); + // Cache removal failures are non-fatal + logger.warn({ message: 'Failed to invalidate calls cache', context: { error } }); } return response.data; @@ -189,8 +190,8 @@ export const closeCall = async (callData: CloseCallRequest) => { try { cacheManager.remove('/Calls/GetActiveCalls'); } catch (error) { - // Silently handle cache removal errors - console.warn('Failed to invalidate calls cache:', error); + // Cache removal failures are non-fatal + logger.warn({ message: 'Failed to invalidate calls cache', context: { error } }); } return response.data; diff --git a/src/api/common/__tests__/api-provider.test.ts b/src/api/common/__tests__/api-provider.test.ts new file mode 100644 index 00000000..5eeac7de --- /dev/null +++ b/src/api/common/__tests__/api-provider.test.ts @@ -0,0 +1,30 @@ +jest.mock('@dev-plugins/react-query', () => ({ + useReactQueryDevTools: jest.fn(), +})); + +import { queryClient } from '../api-provider'; + +describe('shared QueryClient defaults', () => { + const defaults = queryClient.getDefaultOptions().queries; + + it('applies a modest stale time instead of refetching on every mount', () => { + expect(defaults?.staleTime).toBe(30 * 1000); + }); + + it('retries failed queries a bounded number of times', () => { + expect(defaults?.retry).toBe(2); + }); + + it('does not refetch on window focus', () => { + // On web/Electron every tab focus would otherwise re-run every mounted query. + expect(defaults?.refetchOnWindowFocus).toBe(false); + }); + + it('leaves per-query options free to override the defaults', () => { + // Consumers pass their own enabled/queryFn/staleTime; defaults must not be + // frozen into the client in a way that blocks that. + const observer = queryClient.defaultQueryOptions({ queryKey: ['x'], staleTime: 0, retry: false }); + expect(observer.staleTime).toBe(0); + expect(observer.retry).toBe(false); + }); +}); diff --git a/src/api/common/__tests__/client.test.ts b/src/api/common/__tests__/client.test.ts index 236910ae..c573176b 100644 --- a/src/api/common/__tests__/client.test.ts +++ b/src/api/common/__tests__/client.test.ts @@ -18,10 +18,17 @@ const mockAxiosInstance = Object.assign(jest.fn(), { }, }); +// Records the config the client passes to axios.create, so the assertions below +// survive both jest.isolateModules and the beforeEach clearAllMocks(). +const mockCreateConfigs: Record[] = []; + jest.mock('axios', () => ({ __esModule: true, default: { - create: jest.fn(() => mockAxiosInstance), + create: jest.fn((config: Record) => { + mockCreateConfigs.push(config); + return mockAxiosInstance; + }), }, })); @@ -87,4 +94,10 @@ describe('API client token refresh logging', () => { }); expect(getHeader).toHaveBeenCalledWith('x-trace-id'); }); + + it('configures a request timeout so a hung request cannot stall the refresh queue', () => { + // Axios defaults to no timeout: a hung request would hold the single-flight + // refresh promise and every 401-queued request behind it, indefinitely. + expect(mockCreateConfigs[0]).toMatchObject({ timeout: 30000 }); + }); }); diff --git a/src/api/common/api-provider.tsx b/src/api/common/api-provider.tsx index ff1e48dd..2ba842ab 100644 --- a/src/api/common/api-provider.tsx +++ b/src/api/common/api-provider.tsx @@ -2,7 +2,15 @@ import { useReactQueryDevTools } from '@dev-plugins/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as React from 'react'; -export const queryClient = new QueryClient(); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + retry: 2, + refetchOnWindowFocus: false, + }, + }, +}); export function APIProvider({ children }: { children: React.ReactNode }) { useReactQueryDevTools(queryClient); diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index ae98e69e..5cc8ba85 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -7,6 +7,9 @@ import useAuthStore from '@/stores/auth/store'; // Create axios instance with default config const axiosInstance: AxiosInstance = axios.create({ baseURL: getBaseApiUrl(), + // Axios defaults to no timeout — a hung request would otherwise hold the + // single-flight refresh (and every 401-queued request behind it) forever. + timeout: 30000, headers: { 'Content-Type': 'application/json', }, diff --git a/src/api/notes/notes.ts b/src/api/notes/notes.ts index 14175fb4..a7faee30 100644 --- a/src/api/notes/notes.ts +++ b/src/api/notes/notes.ts @@ -63,8 +63,8 @@ export const saveNote = async (data: SaveNoteInput) => { const response = await saveNoteApi.post({ ...data, }); - // The notes list is cached for 2 days — invalidate so the new/updated note - // is visible immediately instead of after cache expiry. + // The notes list is cached for 15 minutes — invalidate so the new/updated + // note is visible immediately instead of after cache expiry. cacheManager.remove('/Notes/GetAllNotes'); return response.data; }; diff --git a/src/app/(app)/__tests__/calls.test.tsx b/src/app/(app)/__tests__/calls.test.tsx index da6b63f9..75798c1f 100644 --- a/src/app/(app)/__tests__/calls.test.tsx +++ b/src/app/(app)/__tests__/calls.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { router } from 'expo-router'; import React from 'react'; @@ -8,9 +8,11 @@ jest.mock('react-native', () => ({ OS: 'ios', }, Pressable: ({ children, onPress, testID, ...props }: any) => ( - + ), - RefreshControl: () => null, + RefreshControl: ({ refreshing, onRefresh }: any) =>
, View: ({ children, ...props }: any) =>
{children}
, StatusBar: { setBackgroundColor: jest.fn(), @@ -72,7 +74,7 @@ const mockAnalytics = { // Mock the stores with proper getState method jest.mock('@/stores/calls/store', () => { - const useCallsStore = jest.fn((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + const useCallsStore = jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); (useCallsStore as any).getState = jest.fn(() => mockCallsStore); return { @@ -82,7 +84,7 @@ jest.mock('@/stores/calls/store', () => { jest.mock('@/stores/security/store', () => ({ securityStore: jest.fn(), - useSecurityStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore), + useSecurityStore: jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore)), })); jest.mock('@/hooks/use-analytics', () => ({ @@ -98,11 +100,7 @@ jest.mock('react-i18next', () => ({ // Mock components jest.mock('@/components/calls/call-card', () => ({ - CallCard: ({ call }: any) => ( -
- {call.Nature} -
- ), + CallCard: ({ call }: any) =>
{call.Nature}
, })); jest.mock('@/components/common/loading', () => ({ @@ -112,7 +110,7 @@ jest.mock('@/components/common/loading', () => ({ jest.mock('@/components/common/zero-state', () => ({ __esModule: true, default: ({ heading, description, isError }: any) => ( -
+
{heading} - {description}
), @@ -121,7 +119,9 @@ jest.mock('@/components/common/zero-state', () => ({ // Mock UI components jest.mock('@/components/ui/box', () => ({ Box: ({ children, className, ...props }: any) => ( -
{children}
+
+ {children} +
), })); @@ -129,13 +129,7 @@ jest.mock('@/components/ui/fab', () => ({ Fab: ({ children, onPress, testID = 'fab', ...props }: any) => { const handleClick = onPress; return ( - ); @@ -144,35 +138,22 @@ jest.mock('@/components/ui/fab', () => ({ })); jest.mock('@/components/ui/flat-list', () => ({ - FlatList: ({ data, renderItem, keyExtractor, ListEmptyComponent, ...props }: any) => ( + FlatList: ({ data, renderItem, keyExtractor, ListEmptyComponent, refreshControl, ...props }: any) => (
- {data.length === 0 && ListEmptyComponent ? ( -
{ListEmptyComponent}
- ) : ( - data.map((item: any, index: number) => ( -
- {renderItem({ item, index })} -
- )) - )} + {refreshControl} + {data.length === 0 && ListEmptyComponent ?
{ListEmptyComponent}
: data.map((item: any, index: number) =>
{renderItem({ item, index })}
)}
), })); jest.mock('@/components/ui/input', () => ({ Input: ({ children, ...props }: any) =>
{children}
, - InputField: ({ value, onChangeText, placeholder, ...props }: any) => ( - onChangeText(e.target.value)} - placeholder={placeholder} - role="textbox" - {...props} - /> - ), + InputField: ({ value, onChangeText, placeholder, ...props }: any) => onChangeText(e.target.value)} placeholder={placeholder} role="textbox" {...props} />, InputIcon: ({ as: IconComponent, ...props }: any) => , InputSlot: ({ children, onPress, ...props }: any) => ( -
{children}
+
+ {children} +
), })); @@ -218,8 +199,8 @@ describe('CallsScreen', () => { jest.clearAllMocks(); // Reset mock returns to defaults - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); - useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore)); useAnalytics.mockReturnValue(mockAnalytics); // Setup securityStore as a selector-based store @@ -247,7 +228,7 @@ describe('CallsScreen', () => { describe('when user has create calls permission', () => { beforeEach(() => { mockSecurityStore.canUserCreateCalls = true; - useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore); + useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore)); }); it('renders the new call FAB button', () => { @@ -277,7 +258,7 @@ describe('CallsScreen', () => { describe('when user does not have create calls permission', () => { beforeEach(() => { mockSecurityStore.canUserCreateCalls = false; - useSecurityStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore); + useSecurityStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockSecurityStore) : mockSecurityStore)); }); it('does not render the new call FAB button', () => { @@ -305,7 +286,7 @@ describe('CallsScreen', () => { beforeEach(() => { mockCallsStore.calls = mockCalls; - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); }); it('renders call cards for each call', () => { @@ -347,7 +328,7 @@ describe('CallsScreen', () => { describe('loading and error states', () => { it('shows loading state when isLoading is true', () => { mockCallsStore.isLoading = true; - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); render(); @@ -360,7 +341,7 @@ describe('CallsScreen', () => { it('shows error state when there is an error', () => { mockCallsStore.error = 'Network error'; - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); render(); @@ -373,7 +354,7 @@ describe('CallsScreen', () => { it('shows zero state when there are no calls', () => { mockCallsStore.calls = []; - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); render(); @@ -397,14 +378,120 @@ describe('CallsScreen', () => { it('tracks view rendered event with correct parameters', () => { const mockCalls = [{ CallId: 'call-1', Nature: 'Test' }]; mockCallsStore.calls = mockCalls; - useCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); - render(); + const { unmount } = render(); expect(mockAnalytics.trackEvent).toHaveBeenCalledWith('calls_view_rendered', { callsCount: 1, hasSearchQuery: false, }); + + unmount(); + }); + + it('does not fire a tracking event per search keystroke', () => { + mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Test' }]; + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + + const countRendered = () => mockAnalytics.trackEvent.mock.calls.filter((call: any[]) => call[0] === 'calls_view_rendered').length; + + const callsAfterMount = countRendered(); + + // This suite mocks react-native with HTML elements, so the search field is + // reached by prop rather than by role/placeholder query. + const searchInput = view.UNSAFE_getByProps({ role: 'textbox' }); + fireEvent(searchInput, 'change', { target: { value: 'f' } }); + fireEvent(searchInput, 'change', { target: { value: 'fi' } }); + fireEvent(searchInput, 'change', { target: { value: 'fir' } }); + + expect(countRendered()).toBe(callsAfterMount); + + view.unmount(); + }); + }); + + describe('stale-while-revalidate and pull-to-refresh', () => { + const treeText = (view: any) => JSON.stringify(view.toJSON()); + + it('shows the full-screen loader only when loading with no data yet', () => { + mockCallsStore.isLoading = true; + mockCallsStore.calls = []; + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + + expect(treeText(view)).toContain('calls.loading'); + + view.unmount(); + }); + + it('keeps the stale list on screen while refreshing with data present', () => { + mockCallsStore.isLoading = true; + mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }]; + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + const html = treeText(view); + + expect(html).not.toContain('calls.loading'); + expect(html).toContain('calls-list'); + + view.unmount(); + }); + + it('does not replace the list with an error state while stale data is showing', () => { + mockCallsStore.error = 'Failed to fetch calls'; + mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }]; + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + const html = treeText(view); + + expect(html).not.toContain('error-state'); + expect(html).toContain('calls-list'); + + view.unmount(); + }); + + it('shows a translated error state when the load failed and there is no data', () => { + mockCallsStore.error = 'Failed to fetch calls'; + mockCallsStore.calls = []; + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + const html = treeText(view); + + expect(html).toContain('error-state'); + expect(html).toContain('calls.errors.load_failed'); + // The raw English store sentinel must not reach the user + expect(html).not.toContain('Failed to fetch calls'); + + view.unmount(); + }); + + it('force-refreshes on pull-to-refresh so the cached endpoint is bypassed', async () => { + mockCallsStore.calls = [{ CallId: 'call-1', Nature: 'Existing' }]; + mockCallsStore.fetchCalls.mockResolvedValue(undefined); + mockCallsStore.fetchCallPriorities.mockResolvedValue(undefined); + useCallsStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(mockCallsStore) : mockCallsStore)); + + const view = render(); + + mockCallsStore.fetchCalls.mockClear(); + + const refreshControl = view.UNSAFE_getByProps({ testID: 'refresh-control' }); + expect(refreshControl.props.refreshing).toBe(false); + + await act(async () => { + await refreshControl.props.onRefresh(); + }); + + expect(mockCallsStore.fetchCalls).toHaveBeenCalledWith(true); + + view.unmount(); }); }); }); diff --git a/src/app/(app)/__tests__/index.test.tsx b/src/app/(app)/__tests__/index.test.tsx index bd45d781..e53a9e87 100644 --- a/src/app/(app)/__tests__/index.test.tsx +++ b/src/app/(app)/__tests__/index.test.tsx @@ -33,24 +33,55 @@ jest.mock('@/hooks/use-map-signalr-updates', () => ({ })); jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn().mockResolvedValue({ - Data: { MapMakerInfos: [] } + Data: { MapMakerInfos: [] }, }), })); -jest.mock('@rnmapbox/maps', () => ({ - setAccessToken: jest.fn(), - MapView: 'MapView', - Camera: 'Camera', - PointAnnotation: 'PointAnnotation', - StyleURL: { - Street: 'mapbox://styles/mapbox/streets-v11', - Dark: 'mapbox://styles/mapbox/dark-v10', - Light: 'mapbox://styles/mapbox/light-v10', - }, - UserTrackingMode: { - Follow: 'follow', - FollowWithHeading: 'followWithHeading', - }, -})); +// Camera commands issued by the screen. The MapView mock reports the map ready +// on mount and the Camera mock exposes a real imperative handle, so the +// follow-camera effects actually run in tests. +const mockSetCamera = jest.fn(); + +jest.mock('@rnmapbox/maps', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + const MapView = ({ children, onDidFinishLoadingMap, ...props }: any) => { + ReactActual.useEffect(() => { + onDidFinishLoadingMap?.(); + // Fire once — mirrors the native "map finished loading" callback. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ReactActual.createElement(View, props, children); + }; + + const Camera = ReactActual.forwardRef((_props: any, ref: any) => { + ReactActual.useImperativeHandle(ref, () => ({ setCamera: mockSetCamera }), []); + return null; + }); + + return { + setAccessToken: jest.fn(), + MapView, + Camera, + PointAnnotation: 'PointAnnotation', + MarkerView: 'MarkerView', + ShapeSource: 'ShapeSource', + SymbolLayer: 'SymbolLayer', + CircleLayer: 'CircleLayer', + LineLayer: 'LineLayer', + FillLayer: 'FillLayer', + Images: 'Images', + StyleURL: { + Street: 'mapbox://styles/mapbox/streets-v11', + Dark: 'mapbox://styles/mapbox/dark-v10', + Light: 'mapbox://styles/mapbox/light-v10', + }, + UserTrackingMode: { + Follow: 'follow', + FollowWithHeading: 'followWithHeading', + }, + }; +}); jest.mock('expo-router', () => ({ useIsFocused: jest.fn(() => true), useNavigation: jest.fn(() => ({ @@ -67,9 +98,14 @@ jest.mock('expo-router', () => ({ replace: jest.fn(), back: jest.fn(), }), - useFocusEffect: jest.fn(() => { - // Don't call the callback to prevent infinite loops in tests - }), + // Mirror the real hook: run the effect, re-running only when the callback + // identity changes. The screen's focus callback is dependency-stable, so this + // fires once per mount — a callback that churned per lock toggle (the bug the + // consolidated camera effect fixes) would show up here as extra setCamera calls. + useFocusEffect: (callback: any) => { + const ReactActual = jest.requireActual('react'); + ReactActual.useEffect(() => callback(), [callback]); + }, })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -77,17 +113,20 @@ jest.mock('react-i18next', () => ({ }), })); jest.mock('@/stores/toast/store', () => ({ - useToastStore: (selector: any) => typeof selector === 'function' ? selector({ - showToast: jest.fn(), - getState: () => ({ - showToast: jest.fn(), - }), - }) : { - showToast: jest.fn(), - getState: () => ({ - showToast: jest.fn(), - }), - }, + useToastStore: (selector: any) => + typeof selector === 'function' + ? selector({ + showToast: jest.fn(), + getState: () => ({ + showToast: jest.fn(), + }), + }) + : { + showToast: jest.fn(), + getState: () => ({ + showToast: jest.fn(), + }), + }, })); jest.mock('@/stores/app/core-store', () => { const storeState = { @@ -98,7 +137,7 @@ jest.mock('@/stores/app/core-store', () => { activeUnit: null, activeUnitStatus: null, }; - const mockFn = jest.fn((selector) => typeof selector === 'function' ? selector(storeState) : storeState) as jest.Mock & { getState: () => typeof storeState }; + const mockFn = jest.fn((selector) => (typeof selector === 'function' ? selector(storeState) : storeState)) as jest.Mock & { getState: () => typeof storeState }; mockFn.getState = () => storeState; return { useCoreStore: mockFn }; }); @@ -117,9 +156,7 @@ jest.mock('@/hooks/use-analytics', () => ({ })); // Test wrapper component -const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} -); +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}; jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null, })); @@ -132,11 +169,21 @@ const mockUseColorScheme = useColorScheme as jest.MockedFunction = { ...defaultLocationState }; + +const setLocationState = (next: Record) => { + currentLocationState = { ...currentLocationState, ...next }; + mockUseLocationStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(currentLocationState) : currentLocationState)); + (useLocationStore as any).getState = () => currentLocationState; +}; + const defaultAppLifecycleState = { isActive: true, appState: 'active' as const, @@ -149,8 +196,12 @@ describe('Map Component - App Lifecycle', () => { jest.clearAllMocks(); jest.useFakeTimers(); + mockSetCamera.mockClear(); + // Setup default mocks with stable objects - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(defaultLocationState) : defaultLocationState); + currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 }; + (useLocationStore as any).getState = () => currentLocationState; + mockUseLocationStore.mockImplementation((selector: any) => (typeof selector === 'function' ? selector(defaultLocationState) : defaultLocationState)); mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState); mockUseColorScheme.mockReturnValue({ colorScheme: 'light', @@ -225,24 +276,32 @@ describe('Map Component - App Lifecycle', () => { it('should handle map lock state changes', async () => { // Start with unlocked map - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...defaultLocationState, - isMapLocked: false, - }) : { - ...defaultLocationState, - isMapLocked: false, - }); + mockUseLocationStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...defaultLocationState, + isMapLocked: false, + }) + : { + ...defaultLocationState, + isMapLocked: false, + } + ); const { rerender, unmount } = render(, { wrapper: TestWrapper }); // Change to locked map - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...defaultLocationState, - isMapLocked: true, - }) : { - ...defaultLocationState, - isMapLocked: true, - }); + mockUseLocationStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...defaultLocationState, + isMapLocked: true, + }) + : { + ...defaultLocationState, + isMapLocked: true, + } + ); rerender(); @@ -255,15 +314,19 @@ describe('Map Component - App Lifecycle', () => { it('should handle navigation mode with heading', async () => { // Mock locked map with heading - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...defaultLocationState, - heading: 90, - isMapLocked: true, - }) : { - ...defaultLocationState, - heading: 90, - isMapLocked: true, - }); + mockUseLocationStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + ...defaultLocationState, + heading: 90, + isMapLocked: true, + }) + : { + ...defaultLocationState, + heading: 90, + isMapLocked: true, + } + ); const { unmount } = render(, { wrapper: TestWrapper }); @@ -363,4 +426,179 @@ describe('Map Component - App Lifecycle', () => { // Note: The analytics tracking is tested indirectly since we can't easily mock it in this setup unmount(); }); -}); \ No newline at end of file +}); +describe('Map Component - follow camera', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockSetCamera.mockClear(); + + mockUseAppLifecycle.mockReturnValue(defaultAppLifecycleState); + mockUseColorScheme.mockReturnValue({ + colorScheme: 'light', + setColorScheme: jest.fn(), + toggleColorScheme: jest.fn(), + }); + + mockLocationService.startLocationUpdates = jest.fn().mockResolvedValue(undefined); + mockLocationService.stopLocationUpdates = jest.fn().mockResolvedValue(undefined); + + currentLocationState = { ...defaultLocationState, speed: 0, accuracy: 10 }; + setLocationState({}); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + const lastCameraConfig = () => mockSetCamera.mock.calls[mockSetCamera.mock.calls.length - 1][0]; + + it('issues exactly one camera command when the map becomes ready', () => { + const { unmount } = render(, { wrapper: TestWrapper }); + + // The focus effect and the camera effect must not both fire here. + expect(mockSetCamera).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('issues exactly one camera command per lock toggle', () => { + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + expect(mockSetCamera).toHaveBeenCalledTimes(1); + mockSetCamera.mockClear(); + + // Lock the map. + setLocationState({ isMapLocked: true }); + rerender(); + expect(mockSetCamera).toHaveBeenCalledTimes(1); + + // ...and unlock it again. + mockSetCamera.mockClear(); + setLocationState({ isMapLocked: false }); + rerender(); + expect(mockSetCamera).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('delivers a throttled location update on the trailing edge', () => { + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + expect(mockSetCamera).toHaveBeenCalledTimes(1); + mockSetCamera.mockClear(); + + // A fix arriving inside the 5s throttle window is dropped... + jest.advanceTimersByTime(1000); + setLocationState({ latitude: 41.0, longitude: -75.0 }); + rerender(); + expect(mockSetCamera).not.toHaveBeenCalled(); + + // ...but replayed once the window expires, so the camera can't park behind + // a unit whose last movement landed inside the window. + jest.advanceTimersByTime(4000); + expect(mockSetCamera).toHaveBeenCalledTimes(1); + expect(lastCameraConfig().centerCoordinate).toEqual([-75.0, 41.0]); + + unmount(); + }); + + it('replays only the newest dropped update on the trailing edge', () => { + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + mockSetCamera.mockClear(); + + jest.advanceTimersByTime(500); + setLocationState({ latitude: 41.0, longitude: -75.0 }); + rerender(); + + jest.advanceTimersByTime(500); + setLocationState({ latitude: 42.0, longitude: -76.0 }); + rerender(); + + expect(mockSetCamera).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(5000); + // Superseded, not queued: one command carrying the latest fix. + expect(mockSetCamera).toHaveBeenCalledTimes(1); + expect(lastCameraConfig().centerCoordinate).toEqual([-76.0, 42.0]); + + unmount(); + }); + + it('does not fire a trailing update after unmount', () => { + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + mockSetCamera.mockClear(); + + jest.advanceTimersByTime(1000); + setLocationState({ latitude: 41.0, longitude: -75.0 }); + rerender(); + + unmount(); + jest.advanceTimersByTime(10000); + expect(mockSetCamera).not.toHaveBeenCalled(); + }); + + // The camera pitch is driven by the *smoothed* speed, so these steps are + // chosen to land it inside the 0.7–1.5 m/s dead band, where the old + // hard "> 1 m/s" rule and the hysteresis rule disagree. + it('holds top-down while smoothed speed is still inside the dead band', () => { + setLocationState({ heading: 90, speed: 0 }); + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + expect(lastCameraConfig().pitch).toBe(0); + + // One 3 m/s fix from a standstill smooths to 1.2 m/s. The old rule tilted + // here; hysteresis holds top-down until 1.5 m/s is cleared. + jest.advanceTimersByTime(6000); + setLocationState({ speed: 3, latitude: 40.72 }); + rerender(); + expect(lastCameraConfig().pitch).toBe(0); + + unmount(); + }); + + it('holds the tilt while slowing through the dead band', () => { + setLocationState({ heading: 90, speed: 2 }); + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + + // Settle the smoothed speed at ~2 m/s so the camera is tilted. + for (let i = 0; i < 8; i++) { + jest.advanceTimersByTime(6000); + setLocationState({ speed: 2, latitude: 40.72 + i * 0.01 }); + rerender(); + } + expect(lastCameraConfig().pitch).toBe(45); + + // Stopping smooths 2 → 1.2 (still tilted under either rule)... + jest.advanceTimersByTime(6000); + setLocationState({ speed: 0, latitude: 40.85 }); + rerender(); + expect(lastCameraConfig().pitch).toBe(45); + + // ...then 1.2 → 0.72, which the old rule flattened. Hysteresis keeps the + // tilt until the unit is properly stopped. + jest.advanceTimersByTime(6000); + setLocationState({ speed: 0, latitude: 40.86 }); + rerender(); + expect(lastCameraConfig().pitch).toBe(45); + + // Below 0.7 m/s the camera finally returns to top-down. + jest.advanceTimersByTime(6000); + setLocationState({ speed: 0, latitude: 40.87 }); + rerender(); + expect(lastCameraConfig().pitch).toBe(0); + + unmount(); + }); + + it('tilts once the unit is clearly moving', () => { + setLocationState({ heading: 90, speed: 0 }); + const { rerender, unmount } = render(, { wrapper: TestWrapper }); + + for (let i = 0; i < 6; i++) { + jest.advanceTimersByTime(6000); + setLocationState({ speed: 12, latitude: 40.73 + i * 0.01 }); + rerender(); + } + expect(lastCameraConfig().pitch).toBe(45); + + unmount(); + }); +}); diff --git a/src/app/(app)/__tests__/init-retry-backoff.test.tsx b/src/app/(app)/__tests__/init-retry-backoff.test.tsx new file mode 100644 index 00000000..d5451e81 --- /dev/null +++ b/src/app/(app)/__tests__/init-retry-backoff.test.tsx @@ -0,0 +1,154 @@ +/** + * A failed app initialization used to re-fire immediately, three times: three full + * multi-request bursts back-to-back against a backend that had just failed, then + * silence — the user was left on a spinner with no idea anything had gone wrong. + * + * The layout itself pulls in Mapbox, Novu, push notifications and the whole store + * graph, so — as with init-session-generation.test.tsx — the retry protocol is + * exercised through the same effect shape the layout uses rather than by rendering it. + */ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +const MAX_INIT_RETRIES = 3; + +/** Mirrors the layout's initialization effect: first attempt immediate, retries backed off, toast once at the cap. */ +function useInitRetry(effects: { initializeApp: jest.Mock; showToast: jest.Mock }) { + const [initRetryCount, setInitRetryCount] = React.useState(0); + const hasInitialized = React.useRef(false); + const isInitializing = React.useRef(false); + const hasShownInitFailureToast = React.useRef(false); + + React.useEffect(() => { + const shouldInitialize = !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES; + + if (!shouldInitialize) { + if (!hasInitialized.current && initRetryCount >= MAX_INIT_RETRIES && !hasShownInitFailureToast.current) { + hasShownInitFailureToast.current = true; + effects.showToast('error', 'app.initialization_failed'); + } + return; + } + + if (initRetryCount === 0) { + effects.initializeApp(); + return; + } + + const backoffMs = 1000 * Math.pow(3, initRetryCount - 1); + const retryTimer = setTimeout(() => { + effects.initializeApp(); + }, backoffMs); + return () => clearTimeout(retryTimer); + }, [initRetryCount, effects]); + + return { fail: () => setInitRetryCount((c) => c + 1), initRetryCount }; +} + +describe('app initialization retry backoff', () => { + const effects = { initializeApp: jest.fn(), showToast: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('runs the first initialization attempt immediately', () => { + renderHook(() => useInitRetry(effects)); + + expect(effects.initializeApp).toHaveBeenCalledTimes(1); + }); + + it('backs off 1s, then 3s, then 9s between retries', () => { + const { result } = renderHook(() => useInitRetry(effects)); + expect(effects.initializeApp).toHaveBeenCalledTimes(1); + + // First failure -> retry after 1s, not immediately. + act(() => result.current.fail()); + expect(effects.initializeApp).toHaveBeenCalledTimes(1); + act(() => { + jest.advanceTimersByTime(999); + }); + expect(effects.initializeApp).toHaveBeenCalledTimes(1); + act(() => { + jest.advanceTimersByTime(1); + }); + expect(effects.initializeApp).toHaveBeenCalledTimes(2); + + // Second failure -> 3s. + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(2999); + }); + expect(effects.initializeApp).toHaveBeenCalledTimes(2); + act(() => { + jest.advanceTimersByTime(1); + }); + expect(effects.initializeApp).toHaveBeenCalledTimes(3); + + // Third failure -> retry budget exhausted, no fourth attempt. + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(60000); + }); + expect(effects.initializeApp).toHaveBeenCalledTimes(3); + }); + + it('surfaces a toast once the retry budget is exhausted', () => { + const { result } = renderHook(() => useInitRetry(effects)); + + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(1000); + }); + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(effects.showToast).not.toHaveBeenCalled(); + + act(() => result.current.fail()); + + expect(effects.showToast).toHaveBeenCalledTimes(1); + expect(effects.showToast).toHaveBeenCalledWith('error', 'app.initialization_failed'); + }); + + it('does not repeat the toast on subsequent re-renders', () => { + const { result, rerender } = renderHook(() => useInitRetry(effects)); + + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(1000); + }); + act(() => result.current.fail()); + act(() => { + jest.advanceTimersByTime(3000); + }); + act(() => result.current.fail()); + expect(effects.showToast).toHaveBeenCalledTimes(1); + + rerender({}); + rerender({}); + + expect(effects.showToast).toHaveBeenCalledTimes(1); + }); + + it('cancels a pending retry when the effect is torn down', () => { + const { result, unmount } = renderHook(() => useInitRetry(effects)); + + act(() => result.current.fail()); + unmount(); + + act(() => { + jest.advanceTimersByTime(60000); + }); + + // Only the initial attempt ran; the scheduled retry was cleaned up. + expect(effects.initializeApp).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 7ca442ef..937c203d 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -37,6 +37,7 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; import { isNetworkError } from '@/utils/network'; @@ -89,6 +90,8 @@ export default function TabLayout() { // over can no longer connect hubs or mark the app initialized. const initGeneration = useRef(0); const hasHiddenSplash = useRef(false); + // Guards the "initialization failed" toast so a failure streak surfaces it once. + const hasShownInitFailureToast = useRef(false); const parentRef = useRef(null); // Render counting for diagnostics (web only) @@ -298,6 +301,7 @@ export default function TabLayout() { // initializing". initGeneration.current += 1; isInitializing.current = false; + hasShownInitFailureToast.current = false; if (initRetryCount > 0) { setInitRetryCount(0); @@ -313,7 +317,17 @@ export default function TabLayout() { const isAppInBackground = Platform.OS !== 'web' && appState === 'background'; const shouldInitialize = status === 'signedIn' && !isAppInBackground && !hasInitialized.current && !isInitializing.current && initRetryCount < MAX_INIT_RETRIES; - if (shouldInitialize) { + if (!shouldInitialize) { + // Retry budget exhausted: surface the failure instead of giving up silently. + // Foregrounding the app still retries via the app-resume effect below. + if (status === 'signedIn' && !isAppInBackground && !hasInitialized.current && initRetryCount >= MAX_INIT_RETRIES && !hasShownInitFailureToast.current) { + hasShownInitFailureToast.current = true; + useToastStore.getState().showToast('error', t('app.initialization_failed')); + } + return; + } + + if (initRetryCount === 0) { logger.info({ message: 'Triggering app initialization', context: { @@ -322,8 +336,24 @@ export default function TabLayout() { }, }); initializeApp(); + return; } - }, [status, initializeApp, initRetryCount, appState]); + + // Exponential backoff between retries (1s, 3s, 9s) — without it a failed + // initialization re-fires its full multi-request burst back-to-back. + const backoffMs = 1000 * Math.pow(3, initRetryCount - 1); + logger.info({ + message: 'Scheduling app initialization retry', + context: { + initRetryCount, + backoffMs, + }, + }); + const retryTimer = setTimeout(() => { + initializeApp(); + }, backoffMs); + return () => clearTimeout(retryTimer); + }, [status, initializeApp, initRetryCount, appState, t]); // Handle app resuming from background - separate from initialization useEffect(() => { @@ -595,7 +625,7 @@ export default function TabLayout() { {/* NotificationInbox positioned within the tab content area — only after init and Novu is ready */} - {isInitComplete && novuReady && } + {isInitComplete && novuReady ? : null} diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx index 9cfcf31b..b6146295 100644 --- a/src/app/(app)/calls.tsx +++ b/src/app/(app)/calls.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from 'expo-router'; import { router } from 'expo-router'; import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, View } from 'react-native'; @@ -51,6 +51,7 @@ export default function Calls() { const { t } = useTranslation(); const { trackEvent } = useAnalytics(); const [searchQuery, setSearchQuery] = useState(''); + const [refreshing, setRefreshing] = useState(false); // Fetch data when screen comes into focus useFocusEffect( @@ -72,19 +73,30 @@ export default function Calls() { } }, [calls, fetchCallDispatches]); + // Search query is read through a ref so typing does not fire an analytics + // event per keystroke — the effect below must not depend on searchQuery. + const searchQueryRef = useRef(searchQuery); + useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + // Track when calls view is rendered useEffect(() => { trackEvent('calls_view_rendered', { callsCount: calls.length, - hasSearchQuery: searchQuery.length > 0, + hasSearchQuery: searchQueryRef.current.length > 0, }); - }, [trackEvent, calls.length, searchQuery]); - - const handleRefresh = () => { - fetchCalls(); - fetchCallPriorities(); - // Dispatches will auto-fetch via useEffect when calls update - }; + }, [trackEvent, calls.length]); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + try { + // Force refresh to bypass the cached endpoint; dispatches auto-fetch via useEffect when calls update + await Promise.all([fetchCalls(true), fetchCallPriorities()]); + } finally { + setRefreshing(false); + } + }, [fetchCalls, fetchCallPriorities]); const handleNewCall = () => { router.push('/call/new/'); @@ -108,12 +120,14 @@ export default function Calls() { // Render content based on loading, error, and data states const renderContent = () => { - if (isLoading) { + // Full-screen loader only when there is nothing to show yet; background + // refreshes keep the stale list on screen (stale-while-revalidate). + if (isLoading && calls.length === 0) { return ; } - if (error) { - return ; + if (error && calls.length === 0) { + return ; } return ( @@ -122,7 +136,7 @@ export default function Calls() { data={filteredCalls} renderItem={renderItem} keyExtractor={keyExtractor} - refreshControl={} + refreshControl={} ListEmptyComponent={} contentContainerStyle={{ paddingBottom: 20 }} /> @@ -140,7 +154,7 @@ export default function Calls() { {searchQuery ? ( - setSearchQuery('')}> + setSearchQuery('')} accessibilityRole="button" accessibilityLabel={t('common.clear_search')}> ) : null} diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index a0292c94..71f3d154 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,6 +1,6 @@ import { type Href, Redirect, useFocusEffect, useRouter } from 'expo-router'; import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshControl, ScrollView } from 'react-native'; @@ -22,40 +22,42 @@ import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; import { useChatSystemStatus } from '@/stores/feature-flags/store'; +// Module scope: declaring this inside ChannelRow's render would give it a new +// component type each render, remounting the avatar/icon subtree every time. +function ChannelLeading({ channel, displayName }: { channel: ChatChannelResultData; displayName: string }) { + if (channel.ChannelType === ChatChannelType.DirectMessage) { + return ( + + {displayName} + + ); + } + const isIncident = + channel.ChannelType === ChatChannelType.Incident || + channel.ChannelType === ChatChannelType.IncidentLane || + channel.ChannelType === ChatChannelType.IncidentCommand || + channel.ChannelType === ChatChannelType.IncidentLeads || + channel.ChannelType === ChatChannelType.IncidentDispatch; + const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users; + return ( + + + + ); +} + function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); const unread = channel.UnreadCount > 0; - const isDm = channel.ChannelType === ChatChannelType.DirectMessage; - - const Leading = () => { - if (isDm) { - return ( - - {getChannelDisplayName(channel, t)} - - ); - } - const isIncident = - channel.ChannelType === ChatChannelType.Incident || - channel.ChannelType === ChatChannelType.IncidentLane || - channel.ChannelType === ChatChannelType.IncidentCommand || - channel.ChannelType === ChatChannelType.IncidentLeads || - channel.ChannelType === ChatChannelType.IncidentDispatch; - const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users; - return ( - - - - ); - }; + const displayName = getChannelDisplayName(channel, t); return ( - + - {getChannelDisplayName(channel, t)} + {displayName} {channel.Topic ? ( @@ -104,7 +106,7 @@ export default function ChatScreen() { }, [isChatEnabled]) ); - const grouped = groupChannels(channels); + const grouped = useMemo(() => groupChannels(channels), [channels]); const openChannel = useCallback( (channelId: string) => { diff --git a/src/app/(app)/contacts.tsx b/src/app/(app)/contacts.tsx index 99169fab..9639163f 100644 --- a/src/app/(app)/contacts.tsx +++ b/src/app/(app)/contacts.tsx @@ -30,13 +30,20 @@ export default function Contacts() { fetchContacts(); }, [fetchContacts]); + // Search query is read through a ref so typing does not fire an analytics + // event per keystroke — the effect below must not depend on searchQuery. + const searchQueryRef = React.useRef(searchQuery); + React.useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + // Track when contacts view is rendered React.useEffect(() => { trackEvent('contacts_view_rendered', { contactsCount: contacts.length, - hasSearchQuery: searchQuery.length > 0, + hasSearchQuery: searchQueryRef.current.length > 0, }); - }, [trackEvent, contacts.length, searchQuery]); + }, [trackEvent, contacts.length]); const handleRefresh = React.useCallback(async () => { setRefreshing(true); @@ -83,7 +90,7 @@ export default function Contacts() { {searchQuery ? ( - setSearchQuery('')} testID="clear-search-button"> + setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}> ) : null} diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index 6b9d0dd6..e9c0332e 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -3,7 +3,7 @@ import { NavigationIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Animated, Platform, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { getMapDataAndMarkers } from '@/api/mapping/mapping'; @@ -11,6 +11,7 @@ import { Loading } from '@/components/common/loading'; import MapPins from '@/components/maps/map-pins'; import Mapbox from '@/components/maps/mapbox'; import PinDetailModal from '@/components/maps/pin-detail-modal'; +import UnitLocationMarker from '@/components/maps/unit-location-marker'; import { StopMarker } from '@/components/routes/stop-marker'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { WeatherAlertBanner } from '@/components/weather-alerts/weather-alert-banner'; @@ -20,6 +21,7 @@ import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates'; import { useWeatherAlertBanner } from '@/hooks/use-weather-alert-banner'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; +import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, normalizeHeading, normalizeSpeed, smoothSpeed, zoomForSpeed } from '@/lib/map-camera'; import { getDepartmentMapCenter } from '@/lib/map-center'; import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; import { locationService } from '@/services/location'; @@ -57,12 +59,23 @@ function MapContent() { const mapRef = useRef>(null); const cameraRef = useRef(null); // Using any due to imperative handle const [isMapReady, setIsMapReady] = useState(false); + // Ref mirror so the dependency-stable focus effect and the trailing follow + // timer can read readiness without re-running/capturing a stale value. + const isMapReadyRef = useRef(false); + isMapReadyRef.current = isMapReady; // Track screen focus so camera follow/animations can be stopped on blur. A camera // event delivered while the native map view is tearing down crashes in @rnmapbox/maps // (onCameraChanged use-after-free), so we quiet the camera when the user navigates // away to shrink that window. const [isScreenFocused, setIsScreenFocused] = useState(true); + const isScreenFocusedRef = useRef(true); + isScreenFocusedRef.current = isScreenFocused; const [hasUserMovedMap, setHasUserMovedMap] = useState(false); + // Ref mirror for the trailing follow-camera timer: hasUserMovedMap is + // intentionally excluded from the follow effect's deps, so the timer checks + // this at fire time to avoid recentering over a fresh user pan. + const hasUserMovedMapRef = useRef(false); + hasUserMovedMapRef.current = hasUserMovedMap; const [mapPins, setMapPins] = useState([]); const [selectedPin, setSelectedPin] = useState(null); const [isPinDetailModalOpen, setIsPinDetailModalOpen] = useState(false); @@ -70,6 +83,8 @@ function MapContent() { const locationLatitude = useLocationStore((state) => state.latitude); const locationLongitude = useLocationStore((state) => state.longitude); const locationHeading = useLocationStore((state) => state.heading); + const locationAccuracy = useLocationStore((state) => state.accuracy); + const locationSpeed = useLocationStore((state) => state.speed); const isMapLocked = useLocationStore((state) => state.isMapLocked); // Weather alert banner state @@ -85,6 +100,7 @@ function MapContent() { // Route overlay state const activeUnitId = useCoreStore((state) => state.activeUnitId); + const activeCallId = useCoreStore((state) => state.activeCallId); const activeInstance = useRoutesStore((state) => state.activeInstance); const instanceStops = useRoutesStore((state) => state.instanceStops); const fetchActiveRoute = useRoutesStore((state) => state.fetchActiveRoute); @@ -105,11 +121,81 @@ function MapContent() { const [styleURL, setStyleURL] = useState({ styleURL: getMapStyle() }); - const pulseAnim = useRef(new Animated.Value(1)).current; useMapSignalRUpdates(setMapPins); // Throttle state for programmatic camera follow (see effect below) const lastCameraFollowRef = useRef(0); + // Trailing-edge timer for the follow throttle: the location store dedupes + // identical fixes, so an update dropped inside the throttle window may be the + // last one before the unit stops — deliver it when the window expires. + const trailingFollowTimeoutRef = useRef | null>(null); + + // Follow-camera state: smoothed ground speed drives the zoom level (walking + // pace zooms in tight, highway speed pulls out for lookahead), and the last + // known heading keeps the camera pointed "behind" the unit while stationary + // fixes report no heading. + const smoothedSpeedRef = useRef(null); + const followZoomRef = useRef(null); + const followPitchRef = useRef(0); + const lastBearingRef = useRef(null); + + // Previous lock/ready state, so the single camera effect below can tell a lock + // toggle (or the map first becoming ready) from an ordinary location update. + const prevIsMapLockedRef = useRef(isMapLocked); + const prevIsMapReadyRef = useRef(isMapReady); + + const clearTrailingFollow = useCallback(() => { + if (trailingFollowTimeoutRef.current) { + clearTimeout(trailingFollowTimeoutRef.current); + trailingFollowTimeoutRef.current = null; + } + }, []); + + /** + * Camera config that frames the unit like a navigation app: centered on the + * unit, rotated to its heading, zoomed by its speed, tilted while moving. + * Reads the location store directly so callers always frame the freshest fix. + */ + const buildFollowCamera = useCallback((animationDuration: number) => { + const { latitude, longitude, heading, speed } = useLocationStore.getState(); + if (latitude == null || longitude == null) return null; + + smoothedSpeedRef.current = smoothSpeed(smoothedSpeedRef.current, normalizeSpeed(speed)); + const zoomLevel = applyZoomHysteresis(followZoomRef.current, zoomForSpeed(smoothedSpeedRef.current)); + followZoomRef.current = zoomLevel; + + const currentHeading = normalizeHeading(heading); + if (currentHeading != null) { + lastBearingRef.current = currentHeading; + } + const bearing = lastBearingRef.current; + + // Tilt behind the unit only while it's actually moving with a known + // heading; a stationary crew on foot gets a top-down view. Hysteresis + // keeps the pitch from flipping when speed hovers around the boundary. + const pitch = bearing != null ? applyPitchHysteresis(followPitchRef.current, smoothedSpeedRef.current) : 0; + followPitchRef.current = pitch; + + return { + centerCoordinate: [longitude, latitude] as [number, number], + zoomLevel, + heading: bearing ?? 0, + pitch, + animationDuration, + }; + }, []); + + /** Issue exactly one camera command and (re)arm the follow throttle window. */ + const applyFollowCamera = useCallback( + (animationDuration: number) => { + const cameraConfig = buildFollowCamera(animationDuration); + if (!cameraConfig) return null; + lastCameraFollowRef.current = Date.now(); + cameraRef.current?.setCamera(cameraConfig); + return cameraConfig; + }, + [buildFollowCamera] + ); // Stable initial camera settings so the native Camera renders at the // correct position from the very first frame (fixes Android/iOS centering). @@ -117,8 +203,8 @@ function MapContent() { if (locationLatitude != null && locationLongitude != null) { return { centerCoordinate: [locationLongitude, locationLatitude] as [number, number], - zoomLevel: isMapLocked ? 16 : 12, - heading: 0, + zoomLevel: zoomForSpeed(normalizeSpeed(locationSpeed)), + heading: normalizeHeading(locationHeading) ?? 0, pitch: 0, }; } @@ -130,7 +216,9 @@ function MapContent() { heading: 0, pitch: 0, }; - }, [locationLatitude, locationLongitude, isMapLocked]); + // Initial settings only matter for the first frame — don't churn them on every fix. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Fetch active route overlay data useEffect(() => { @@ -190,18 +278,7 @@ function MapContent() { // Geofence circle GeoJSON const geofenceGeoJSON = useMemo((): GeoJSON.Feature | null => { if (!nextStop || !nextStop.GeofenceRadiusMeters) return null; - const points = 64; - const coords: number[][] = []; - const radiusDeg = nextStop.GeofenceRadiusMeters / 111320; - for (let i = 0; i <= points; i++) { - const angle = (i / points) * 2 * Math.PI; - coords.push([nextStop.Longitude + radiusDeg * Math.cos(angle), nextStop.Latitude + radiusDeg * Math.sin(angle)]); - } - return { - type: 'Feature', - properties: {}, - geometry: { type: 'Polygon', coordinates: [coords] }, - }; + return createCirclePolygon(nextStop.Longitude, nextStop.Latitude, nextStop.GeofenceRadiusMeters); }, [nextStop]); // Update map style when theme changes @@ -210,7 +287,12 @@ function MapContent() { setStyleURL({ styleURL: newStyle }); }, [getMapStyle]); - // Handle navigation focus - reset map state when user navigates back to map page + // Handle navigation focus - reset map state when user navigates back to map page. + // The callback is intentionally dependency-stable (state is read through refs / + // the store) so it only re-runs on genuine focus/blur transitions — with + // isMapReady/isMapLocked in the deps it also re-ran on every lock toggle and + // map-ready flip, issuing a second camera command on top of the lock effect + // below and double-advancing the speed EMA. useFocusEffect( useCallback(() => { // Mark the screen focused again so camera follow/animations are allowed @@ -219,40 +301,28 @@ function MapContent() { // Reset hasUserMovedMap when navigating back to map setHasUserMovedMap(false); - // Reset camera to current location when navigating back to map - if (isMapReady && locationLatitude && locationLongitude) { - const cameraConfig: any = { - centerCoordinate: [locationLongitude, locationLatitude], - zoomLevel: isMapLocked ? 16 : 12, - animationDuration: 1000, - heading: 0, - pitch: 0, - }; - - // Add heading and pitch for navigation mode when locked - if (isMapLocked && locationHeading !== null && locationHeading !== undefined) { - cameraConfig.heading = locationHeading; - cameraConfig.pitch = 45; + // Reset camera to follow the unit when navigating back to map + if (isMapReadyRef.current) { + const cameraConfig = applyFollowCamera(1000); + if (cameraConfig) { + logger.info({ + message: 'Map focused, resetting camera to current location', + context: { + latitude: cameraConfig.centerCoordinate[1], + longitude: cameraConfig.centerCoordinate[0], + isMapLocked: useLocationStore.getState().isMapLocked, + }, + }); } - - cameraRef.current?.setCamera(cameraConfig); - - logger.info({ - message: 'Map focused, resetting camera to current location', - context: { - latitude: locationLatitude, - longitude: locationLongitude, - isMapLocked: isMapLocked, - }, - }); } // On blur (cleanup), stop the camera following/animating before the native // map view is detached, so a camera event can't fire into a torn-down view. return () => { setIsScreenFocused(false); + clearTrailingFollow(); }; - }, [isMapReady, locationLatitude, locationLongitude, isMapLocked, locationHeading]) + }, [applyFollowCamera, clearTrailingFollow]) ); useEffect(() => { @@ -295,63 +365,72 @@ function MapContent() { }; }, []); + // Single driver for programmatic camera moves. Lock toggles, the map first + // becoming ready and ordinary location updates all funnel through here so each + // trigger issues exactly one setCamera — two effects firing back-to-back + // double-advanced the speed EMA and the zoom hysteresis on every lock toggle. useEffect(() => { + const lockChanged = prevIsMapLockedRef.current !== isMapLocked; + const becameReady = isMapReady && !prevIsMapReadyRef.current; + prevIsMapLockedRef.current = isMapLocked; + prevIsMapReadyRef.current = isMapReady; + + // Toggling the lock (either direction) returns the camera to the default + // follow-the-unit behavior and clears any manual pan/zoom the user made. + if (lockChanged) { + setHasUserMovedMap(false); + } + // Skip camera animations while the screen is unfocused so a location update // arriving during/after navigation away doesn't drive the (possibly tearing // down) native map view. - if (isScreenFocused && isMapReady && locationLatitude && locationLongitude) { - // When map is locked, always follow the location - // When map is unlocked, only follow if user hasn't moved the map - if (isMapLocked || !hasUserMovedMap) { - // Throttle programmatic camera moves — GPS fixes arrive every ~15s and - // each setCamera triggers a native camera animation + re-render. - const now = Date.now(); - if (now - lastCameraFollowRef.current < CAMERA_FOLLOW_THROTTLE_MS) { - return; - } - lastCameraFollowRef.current = now; - - const cameraConfig: any = { - centerCoordinate: [locationLongitude, locationLatitude], - zoomLevel: isMapLocked ? 16 : 12, - animationDuration: isMapLocked ? 500 : 1000, - }; - - // Add heading and pitch for navigation mode when locked - if (isMapLocked && locationHeading !== null && locationHeading !== undefined) { - cameraConfig.heading = locationHeading; - cameraConfig.pitch = 45; - } + if (!isScreenFocused || !isMapReady || locationLatitude == null || locationLongitude == null) { + return; + } - cameraRef.current?.setCamera(cameraConfig); - } + // A lock toggle (or the map becoming ready) recenters right away, bypassing + // and re-arming the follow throttle. + if (lockChanged || becameReady) { + clearTrailingFollow(); + applyFollowCamera(800); + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, isMapLocked]); - // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop - // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger. - // Reset hasUserMovedMap when map gets locked and reset camera when unlocked - useEffect(() => { - if (isMapLocked) { - setHasUserMovedMap(false); - } else { - // When exiting locked mode, reset camera to normal view and reset user interaction state - setHasUserMovedMap(false); + // When map is locked, always follow the location. + // When map is unlocked, follow by default — but stop the moment the user + // pans/zooms/rotates the map, until they recenter or toggle the lock. + if (!isMapLocked && hasUserMovedMap) { + return; + } - if (isMapReady && locationLatitude && locationLongitude) { - cameraRef.current?.setCamera({ - centerCoordinate: [locationLongitude, locationLatitude], - zoomLevel: 12, - heading: 0, - pitch: 0, - animationDuration: 1000, - }); - } + // Throttle programmatic camera moves — GPS fixes arrive every ~15s and + // each setCamera triggers a native camera animation + re-render. + const elapsed = Date.now() - lastCameraFollowRef.current; + clearTrailingFollow(); + + if (elapsed < CAMERA_FOLLOW_THROTTLE_MS) { + // The location store dedupes identical fixes, so a dropped update can be + // the last position change before the unit stops. Replay it on the + // trailing edge instead of parking the camera behind the stopped unit. + trailingFollowTimeoutRef.current = setTimeout(() => { + trailingFollowTimeoutRef.current = null; + if (!isScreenFocusedRef.current || !isMapReadyRef.current) return; + if (!useLocationStore.getState().isMapLocked && hasUserMovedMapRef.current) return; + applyFollowCamera(4000); + }, CAMERA_FOLLOW_THROTTLE_MS - elapsed); + return; } - // Only react to lock mode changes — NOT location changes (those are handled above) + + // Long animation glides the camera between sparse fixes instead of + // hopping, approximating continuous navigation-style tracking. + applyFollowCamera(4000); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isMapReady, isMapLocked]); + }, [isScreenFocused, isMapReady, locationLatitude, locationLongitude, locationHeading, locationSpeed, isMapLocked, applyFollowCamera, clearTrailingFollow]); + // NOTE: hasUserMovedMap intentionally excluded from deps to avoid toggle loop + // on web where programmatic easeTo → moveend → setHasUserMovedMap(true) → re-trigger. + + // Drop any pending trailing follow when the map screen goes away. + useEffect(() => clearTrailingFollow, [clearTrailingFollow]); useEffect(() => { const abortController = new AbortController(); @@ -387,32 +466,6 @@ function MapContent() { }; }, []); - // Only run Animated.loop on native — on web, useNativeDriver falls back to JS driver - // which creates continuous requestAnimationFrame overhead. Web uses CSS animation instead. - useEffect(() => { - if (Platform.OS !== 'web') { - const loopAnim = Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.2, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - ]) - ); - loopAnim.start(); - - return () => { - loopAnim.stop(); - }; - } - }, [pulseAnim]); - // Track when map view is rendered useEffect(() => { trackEvent('map_view_rendered', { @@ -434,20 +487,8 @@ function MapContent() { ); const handleRecenterMap = () => { - if (locationLatitude && locationLongitude) { - const cameraConfig: any = { - centerCoordinate: [locationLongitude, locationLatitude], - zoomLevel: isMapLocked ? 16 : 12, - animationDuration: 1000, - }; - - // Add heading and pitch for navigation mode when locked - if (isMapLocked && locationHeading !== null && locationHeading !== undefined) { - cameraConfig.heading = locationHeading; - cameraConfig.pitch = 45; - } - - cameraRef.current?.setCamera(cameraConfig); + clearTrailingFollow(); + if (applyFollowCamera(1000)) { setHasUserMovedMap(false); } }; @@ -489,30 +530,12 @@ function MapContent() { }; // Show recenter button only when map is not locked and user has moved the map - const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude && locationLongitude; + const showRecenterButton = !isMapLocked && hasUserMovedMap && locationLatitude != null && locationLongitude != null; // Create dynamic styles based on theme - useMemo to avoid new objects every render const themedStyles = useMemo(() => { const isDark = colorScheme === 'dark'; return { - markerInnerContainer: { - width: 24, - height: 24, - alignItems: 'center' as const, - justifyContent: 'center' as const, - backgroundColor: '#3b82f6', - borderRadius: 12, - borderWidth: 3, - borderColor: isDark ? '#1f2937' : '#ffffff', - elevation: 5, - shadowColor: isDark ? '#ffffff' : '#000000', - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: isDark ? 0.1 : 0.25, - shadowRadius: 3.84, - }, recenterButton: { position: 'absolute' as const, bottom: 20 + insets.bottom, @@ -559,36 +582,11 @@ function MapContent() { rotateEnabled={!isMapLocked} pitchEnabled={!isMapLocked} > - - - {locationLatitude != null && locationLongitude != null ? ( - - - - - - {locationHeading != null ? ( - - ) : null} - - - - ) : null} - + {/* Camera is driven imperatively (buildFollowCamera) so locked and + unlocked modes share the same speed-adaptive follow behavior. */} + + + {/* Active route polyline overlay */} {routeOverlayGeoJSON ? ( @@ -658,6 +656,10 @@ function MapContent() { ) : null )} + + {/* Unit location indicator: accuracy circle, heading arrow, dot. + Rendered last so its layers draw above the overlay fills. */} + {locationLatitude != null && locationLongitude != null ? : null} {/* Weather Alert Banner */} @@ -688,52 +690,6 @@ const styles = StyleSheet.create({ map: { flex: 1, }, - markerContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 60, - height: 60, - position: 'relative', - }, - markerOuterRing: { - position: 'absolute', - width: 60, - height: 60, - borderRadius: 30, - backgroundColor: 'rgba(59, 130, 246, 0.15)', - borderWidth: 2, - borderColor: 'rgba(59, 130, 246, 0.3)', - }, - markerInnerContainer: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#3b82f6', - borderRadius: 12, - borderWidth: 3, - // borderColor and shadow properties are handled by themedStyles - }, - markerDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: '#ffffff', - }, - directionIndicator: { - position: 'absolute', - width: 0, - height: 0, - backgroundColor: 'transparent', - borderStyle: 'solid', - borderLeftWidth: 8, - borderRightWidth: 8, - borderBottomWidth: 24, - borderLeftColor: 'transparent', - borderRightColor: 'transparent', - borderBottomColor: '#3b82f6', - top: -36, - }, recenterButton: { position: 'absolute', bottom: 20, @@ -746,10 +702,4 @@ const styles = StyleSheet.create({ alignItems: 'center', // elevation and shadow properties are handled by themedStyles }, - // Web-only CSS pulse animation (replaces Animated.loop which falls back to JS driver on web). - // Applied via the global `.pulse-ring` CSS class — react-native-web rejects - // `animationName` as an inline style property. - markerPulseWeb: { - // No JS-driven transform on web — the outer ring animates via CSS instead - } as any, }); diff --git a/src/app/(app)/notes.tsx b/src/app/(app)/notes.tsx index bef15bc0..eb3c14c4 100644 --- a/src/app/(app)/notes.tsx +++ b/src/app/(app)/notes.tsx @@ -30,13 +30,20 @@ export default function Notes() { fetchNotes(); }, [fetchNotes]); + // Search query is read through a ref so typing does not fire an analytics + // event per keystroke — the effect below must not depend on searchQuery. + const searchQueryRef = React.useRef(searchQuery); + React.useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + // Track when notes view is rendered React.useEffect(() => { trackEvent('notes_view_rendered', { notesCount: notes.length, - hasSearchQuery: searchQuery.length > 0, + hasSearchQuery: searchQueryRef.current.length > 0, }); - }, [trackEvent, notes.length, searchQuery]); + }, [trackEvent, notes.length]); const handleRefresh = React.useCallback(async () => { setRefreshing(true); @@ -65,7 +72,7 @@ export default function Notes() { {searchQuery ? ( - setSearchQuery('')}> + setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}> ) : null} diff --git a/src/app/(app)/protocols.tsx b/src/app/(app)/protocols.tsx index e57408fe..a7149b1b 100644 --- a/src/app/(app)/protocols.tsx +++ b/src/app/(app)/protocols.tsx @@ -31,13 +31,20 @@ export default function Protocols() { fetchProtocols(); }, [fetchProtocols]); + // Search query is read through a ref so typing does not fire an analytics + // event per keystroke — the effect below must not depend on searchQuery. + const searchQueryRef = React.useRef(searchQuery); + React.useEffect(() => { + searchQueryRef.current = searchQuery; + }, [searchQuery]); + // Track when protocols view is rendered React.useEffect(() => { trackEvent('protocols_view_rendered', { protocolsCount: protocols.length, - hasSearchQuery: searchQuery.length > 0, + hasSearchQuery: searchQueryRef.current.length > 0, }); - }, [trackEvent, protocols.length, searchQuery.length]); + }, [trackEvent, protocols.length]); const handleRefresh = React.useCallback(async () => { setRefreshing(true); @@ -72,7 +79,7 @@ export default function Protocols() { {searchQuery ? ( - setSearchQuery('')}> + setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}> ) : null} diff --git a/src/app/(app)/weather-alerts.tsx b/src/app/(app)/weather-alerts.tsx index 4f5ab76c..fc198ac9 100644 --- a/src/app/(app)/weather-alerts.tsx +++ b/src/app/(app)/weather-alerts.tsx @@ -106,7 +106,7 @@ export default function WeatherAlerts() { {searchQuery ? ( - setSearchQuery('')}> + setSearchQuery('')} testID="clear-search-button" accessibilityRole="button" accessibilityLabel={t('common.clear_search')}> ) : null} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 8b9faa54..f95e782d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -103,7 +103,6 @@ if (Platform.OS !== 'web') { // Load the selected theme from storage and apply it loadSelectedTheme(); -//useAuth().hydrate(); // Prevent the splash screen from auto-hiding before asset loading is complete. //SplashScreen.preventAutoHideAsync(); // Set the animation options. This is optional. diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 0bcc6b2f..4285338f 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -82,9 +82,9 @@ export default function CallDetail() { const stopPolling = useCheckInTimerStore((state) => state.stopPolling); const resetTimers = useCheckInTimerStore((state) => state.reset); - // Get current user location from the location store - const userLatitude = useLocationStore((state) => state.latitude); - const userLongitude = useLocationStore((state) => state.longitude); + // NOTE: the user's location is read via useLocationStore.getState() inside the + // route handlers instead of subscribing — subscribing re-rendered this whole + // screen (tab tree + WebViews) on every GPS fix. const handleBack = () => { router.back(); @@ -237,6 +237,7 @@ export default function CallDetail() { try { const destinationName = call?.Address || t('call_detail.call_location'); + const { latitude: userLatitude, longitude: userLongitude } = useLocationStore.getState(); const success = await openMapsWithDirections(coordinates.latitude, coordinates.longitude, destinationName, userLatitude || undefined, userLongitude || undefined); if (!success) { @@ -265,6 +266,7 @@ export default function CallDetail() { try { const destinationName = call?.DestinationName || call?.DestinationAddress || t('call_detail.destination'); + const { latitude: userLatitude, longitude: userLongitude } = useLocationStore.getState(); const success = await openMapsWithDirections(latitude, longitude, destinationName, userLatitude || undefined, userLongitude || undefined); if (!success) { @@ -565,12 +567,12 @@ export default function CallDetail() { {call.Name} ({call.Number}) {/* Show "Set Active" button if this call is not the active call and there is an active unit */} - {activeUnit && activeCall?.CallId !== call.CallId && ( + {activeUnit && activeCall?.CallId !== call.CallId ? ( - )} + ) : null} diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx index 81fcc84d..61f5fb5f 100644 --- a/src/app/call/[id]/edit.tsx +++ b/src/app/call/[id]/edit.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import * as z from 'zod'; import { DestinationPoiSelector } from '@/components/calls/destination-poi-selector'; @@ -17,7 +18,7 @@ import FullScreenLocationPicker from '@/components/maps/full-screen-location-pic import LocationPicker from '@/components/maps/location-picker'; import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; +import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; import { Input, InputField } from '@/components/ui/input'; @@ -26,6 +27,7 @@ import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/toast'; import { useAnalytics } from '@/hooks/use-analytics'; +import { logger } from '@/lib/logging'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -118,7 +120,7 @@ export default function EditCall() { const { control, handleSubmit, - formState: { errors }, + formState: { errors, isSubmitting }, setValue, reset, } = useForm({ @@ -282,7 +284,7 @@ export default function EditCall() { // Navigate back to call detail router.back(); } catch (error) { - console.error('Error updating call:', error); + logger.error({ message: 'Error updating call', context: { error } }); // Show error toast toast.show({ @@ -394,7 +396,7 @@ export default function EditCall() { }); } } catch (error) { - console.error('Error geocoding address:', error); + logger.error({ message: 'Error geocoding address', context: { error } }); toast.show({ placement: 'top', @@ -462,7 +464,7 @@ export default function EditCall() { /> - {callDetailError || callDataError || 'Call not found'} + {callDetailError || callDataError || t('call_detail.not_found')} @@ -481,243 +483,246 @@ export default function EditCall() { /> - - {t('calls.edit_call_description')} - - - - - {t('calls.name')} - - ( - - - - )} - /> - {errors.name && ( - - {errors.name.message} - - )} - - - - - - - {t('calls.nature')} - - ( - - )} - /> - {errors.nature && ( - - {errors.nature.message} - - )} - - - - - - - {t('calls.priority')} - - ( - - )} - /> - {errors.priority && ( - - {errors.priority.message} - - )} - - - - - - - {t('calls.type')} - - ( - - )} - /> - {errors.type && ( - - {errors.type.message} - - )} - - - - - - - {t('calls.note')} - - ( - - )} - /> - - - - - {t('calls.call_location')} - - {/* Address Field */} - - - {t('calls.address')} - - ( - - - - - + + + {t('calls.edit_call_description')} + + + + + {t('calls.name')} + + ( + + + + )} + /> + {errors.name ? ( + + {errors.name.message} + + ) : null} + + + + + + + {t('calls.nature')} + + ( + + )} + /> + {errors.nature ? ( + + {errors.nature.message} + + ) : null} + + + + + + + {t('calls.priority')} + + ( + + )} + /> + {errors.priority ? ( + + {errors.priority.message} + + ) : null} + + + + + + + {t('calls.type')} + + ( + + )} + /> + {errors.type ? ( + + {errors.type.message} + + ) : null} + + + + + + + {t('calls.note')} + + ( + + )} + /> + + + + + {t('calls.call_location')} + + {/* Address Field */} + + + {t('calls.address')} + + ( + + + + + + + - - - )} - /> - - - {/* Map Preview */} - - {selectedLocation ? ( - - ) : ( - - )} - - - ( - onChange(poiId != null ? poiId.toString() : '')} + )} /> - )} - /> - - - - - - {t('calls.contact_name')} - - ( - - - + + + {/* Map Preview */} + + {selectedLocation ? ( + + ) : ( + )} - /> - - - - - - - {t('calls.contact_info')} - + + ( - - - + name="destinationPoiId" + render={({ field: { onChange, value } }) => ( + onChange(poiId != null ? poiId.toString() : '')} + /> )} /> - - - - - {t('calls.dispatch_to')} - - - - - - - - + + + + + + {t('calls.contact_name')} + + ( + + + + )} + /> + + + + + + + {t('calls.contact_info')} + + ( + + + + )} + /> + + + + + {t('calls.dispatch_to')} + + + + + + + + + {/* Full-screen location picker overlay */} - {showLocationPicker && ( + {showLocationPicker ? ( setShowLocationPicker(false)} /> - )} + ) : null} {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index eb18bbdf..e2ca35ab 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -15,8 +15,6 @@ import { openMapsWithDirections } from '@/lib/navigation'; import CallDetail from '../[id]'; - - // Mock UI components that might use NativeWind jest.mock('@/components/ui', () => ({ FocusAwareStatusBar: jest.fn().mockImplementation(() => null), @@ -31,21 +29,25 @@ jest.mock('@/components/ui/button', () => ({ Button: jest.fn().mockImplementation(({ children, onPress, disabled, ...props }) => { const React = require('react'); - return React.createElement('button', { - onPress, - onClick: onPress, // For web compatibility - disabled, - accessibilityRole: 'button', - accessibilityLabel: React.Children.toArray(children).map((child: any) => - typeof child === 'string' ? child : - child?.props?.children || '' - ).join(' '), - testID: `button-${React.Children.toArray(children).map((child: any) => - typeof child === 'string' ? child : - child?.props?.children || '' - ).join(' ').toLowerCase().replace(/\s+/g, '-')}`, - ...props - }, children); + return React.createElement( + 'button', + { + onPress, + onClick: onPress, // For web compatibility + disabled, + accessibilityRole: 'button', + accessibilityLabel: React.Children.toArray(children) + .map((child: any) => (typeof child === 'string' ? child : child?.props?.children || '')) + .join(' '), + testID: `button-${React.Children.toArray(children) + .map((child: any) => (typeof child === 'string' ? child : child?.props?.children || '')) + .join(' ') + .toLowerCase() + .replace(/\s+/g, '-')}`, + ...props, + }, + children + ); }), ButtonIcon: jest.fn().mockImplementation(() => null), ButtonText: jest.fn().mockImplementation(({ children }) => children), @@ -89,13 +91,13 @@ jest.mock('expo-clipboard', () => ({ jest.mock('expo-constants', () => ({ expoConfig: { extra: { - IS_MOBILE_APP: "true", + IS_MOBILE_APP: 'true', }, }, default: { expoConfig: { extra: { - IS_MOBILE_APP: "true", + IS_MOBILE_APP: 'true', }, }, }, @@ -104,7 +106,7 @@ jest.mock('expo-constants', () => ({ // Mock @env to prevent expo-constants issues jest.mock('@env', () => ({ Env: { - IS_MOBILE_APP: "true", + IS_MOBILE_APP: 'true', }, })); @@ -131,7 +133,11 @@ jest.mock('axios', () => { // Mock query-string jest.mock('query-string', () => ({ - stringify: jest.fn((obj) => Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&')), + stringify: jest.fn((obj) => + Object.keys(obj) + .map((key) => `${key}=${obj[key]}`) + .join('&') + ), })); // Mock auth store @@ -355,19 +361,19 @@ jest.mock('react-native', () => ({ }, Platform: { OS: 'ios', - select: jest.fn(options => options.ios), + select: jest.fn((options) => options.ios), }, StyleSheet: { - create: jest.fn(styles => styles), - flatten: jest.fn(style => style), + create: jest.fn((styles) => styles), + flatten: jest.fn((style) => style), }, Appearance: { getColorScheme: jest.fn(() => 'light'), addEventListener: jest.fn((eventType, callback) => ({ - remove: jest.fn() + remove: jest.fn(), })), addChangeListener: jest.fn((callback) => ({ - remove: jest.fn() + remove: jest.fn(), })), removeChangeListener: jest.fn(), isReduceMotionEnabled: jest.fn(() => false), @@ -375,7 +381,7 @@ jest.mock('react-native', () => ({ AccessibilityInfo: { isReduceMotionEnabled: jest.fn(() => Promise.resolve(false)), addEventListener: jest.fn((eventType, callback) => ({ - remove: jest.fn() + remove: jest.fn(), })), removeEventListener: jest.fn(), }, @@ -484,7 +490,7 @@ describe('CallDetail', () => { const defaultLocationStore = { latitude: 40.7128, - longitude: -74.0060, + longitude: -74.006, }; const defaultStatusBottomSheetStore = { @@ -515,6 +521,9 @@ describe('CallDetail', () => { } return defaultLocationStore; }); + // The screen reads the user's location via getState() inside the route handlers + // rather than subscribing, so every GPS fix no longer re-renders the tab tree. + (mockUseLocationStore as unknown as { getState: jest.Mock }).getState = jest.fn(() => defaultLocationStore); mockUseToastStore.mockImplementation((selector: any) => { const store = { showToast: jest.fn() }; diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx index ad280a97..13322d2d 100644 --- a/src/app/call/new/index.tsx +++ b/src/app/call/new/index.tsx @@ -1,5 +1,4 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { render } from '@testing-library/react-native'; import axios from 'axios'; import * as Location from 'expo-location'; import { router, Stack } from 'expo-router'; @@ -9,6 +8,7 @@ import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ScrollView, View } from 'react-native'; +import { KeyboardAvoidingView } from 'react-native-keyboard-controller'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as z from 'zod'; @@ -21,7 +21,7 @@ import FullScreenLocationPicker from '@/components/maps/full-screen-location-pic import LocationPicker from '@/components/maps/location-picker'; import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; +import { Button, ButtonSpinner, ButtonText } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { FormControl, FormControlError, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; @@ -32,6 +32,7 @@ import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { useAnalytics } from '@/hooks/use-analytics'; import { useNewCallFieldPolicy } from '@/hooks/use-new-call-field-policy'; import { useToast } from '@/hooks/use-toast'; +import { logger } from '@/lib/logging'; import { type NewCallFieldKey, NewCallFieldKeys } from '@/models/v4/calls/newCallFieldPolicyResultData'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; @@ -172,7 +173,7 @@ export default function NewCall() { const { control, handleSubmit, - formState: { errors }, + formState: { errors, isSubmitting }, setValue, } = useForm({ resolver: zodResolver(formSchema), @@ -302,7 +303,7 @@ export default function NewCall() { // Navigate back to calls list router.push('/calls'); } catch (error) { - console.error('Error creating call:', error); + logger.error({ message: 'Error creating call', context: { error } }); // Show error toast toast.error(t('calls.create_error')); @@ -406,7 +407,7 @@ export default function NewCall() { toast.error(t('calls.address_not_found')); } } catch (error) { - console.error('Error geocoding address:', error); + logger.error({ message: 'Error geocoding address', context: { error } }); // Show error toast toast.error(t('calls.geocoding_error')); @@ -486,7 +487,7 @@ export default function NewCall() { toast.error(t('calls.what3words_not_found')); } } catch (error) { - console.error('Error geocoding what3words:', error); + logger.error({ message: 'Error geocoding what3words', context: { error } }); // Show error toast toast.error(t('calls.what3words_geocoding_error')); @@ -544,7 +545,7 @@ export default function NewCall() { toast.error(t('calls.plus_code_not_found')); } } catch (error) { - console.error('Error geocoding plus code:', error); + logger.error({ message: 'Error geocoding plus code', context: { error } }); // Show error toast toast.error(t('calls.plus_code_geocoding_error')); @@ -629,7 +630,7 @@ export default function NewCall() { toast.info(t('calls.coordinates_no_address')); } } catch (error) { - console.error('Error reverse geocoding coordinates:', error); + logger.error({ message: 'Error reverse geocoding coordinates', context: { error } }); // Even if geocoding fails, still set the location on the map const newLocation = { @@ -684,335 +685,344 @@ export default function NewCall() { /> - - {t('calls.create_new_call')} - - - - - {t('calls.name')} - - ( - - - - )} - /> - {errors.name && ( - - {errors.name.message} - - )} - - - - - - - {t('calls.nature')} - - ( - - )} - /> - {errors.nature && ( - - {errors.nature.message} - - )} - - - - - - - {t('calls.priority')} - - ( - - )} - /> - {errors.priority && ( - - {errors.priority.message} - - )} - - - - - - - {t('calls.type')} - - ( - - )} - /> - {errors.type && ( - - {errors.type.message} - - )} - - - - {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + + + {t('calls.create_new_call')} + + + + + {t('calls.name')} + + ( + + + + )} + /> + {errors.name ? ( + + {errors.name.message} + + ) : null} + + + - + - {t('calls.note')} + {t('calls.nature')} ( )} /> + {errors.nature ? ( + + {errors.nature.message} + + ) : null} - ) : null} - {showLocationCard ? ( - {t('calls.call_location')} + + + {t('calls.priority')} + + ( + + )} + /> + {errors.priority ? ( + + {errors.priority.message} + + ) : null} + + - {/* Address Field */} - {showAddress ? ( - + + + + {t('calls.type')} + + ( + + )} + /> + {errors.type ? ( + + {errors.type.message} + + ) : null} + + + + {fieldPolicy.isVisible(NewCallFieldKeys.Note) ? ( + + - {t('calls.address')} + {t('calls.note')} ( - - - - - - - - + )} /> - ) : null} + + ) : null} + + {showLocationCard ? ( + + {t('calls.call_location')} + + {/* Address Field */} + {showAddress ? ( + + + {t('calls.address')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* GPS Coordinates Field */} + {showGeolocation ? ( + + + {t('calls.coordinates')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* what3words Field */} + {showWhat3Words ? ( + + + {t('calls.what3words')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* Plus Code Field */} + {showPlusCode ? ( + + + {t('calls.plus_code')} + + ( + + + + + + + + + )} + /> + + ) : null} + + {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */} + {showGeolocation ? ( + + {selectedLocation ? ( + + ) : ( + + )} + + ) : null} - {/* GPS Coordinates Field */} - {showGeolocation ? ( - - - {t('calls.coordinates')} - + {showDestinationPoi ? ( ( - - - - - - - - + name="destinationPoiId" + render={({ field: { onChange, value } }) => ( + onChange(poiId != null ? poiId.toString() : '')} + /> )} /> - - ) : null} + ) : null} + + ) : null} - {/* what3words Field */} - {showWhat3Words ? ( - + {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( + + - {t('calls.what3words')} + {t('calls.contact_name')} ( - - - - - - - - + + + )} /> - ) : null} + + ) : null} - {/* Plus Code Field */} - {showPlusCode ? ( - + {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( + + - {t('calls.plus_code')} + {t('calls.contact_info')} ( - - - - - - - - + + + )} /> - ) : null} - - {/* Map Preview — the map is how a geolocation gets picked, so it follows that field. */} - {showGeolocation ? ( - - {selectedLocation ? ( - - ) : ( - - )} - - ) : null} - - {showDestinationPoi ? ( - ( - onChange(poiId != null ? poiId.toString() : '')} - /> - )} - /> - ) : null} - - ) : null} - - {fieldPolicy.isVisible(NewCallFieldKeys.ContactName) ? ( - - - - {t('calls.contact_name')} - - ( - - - - )} - /> - - - ) : null} - - {fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? ( - - - - {t('calls.contact_info')} - - ( - - - - )} - /> - - - ) : null} - - {showDispatchList ? ( - - {t('calls.dispatch_to')} - + + ) : null} + + + - - ) : null} - - - - - - + + + + {/* Full-screen location picker overlay */} - {showLocationPicker && ( + {showLocationPicker ? ( setShowLocationPicker(false)} /> - )} + ) : null} {/* Dispatch selection modal */} setShowDispatchModal(false)} onConfirm={handleDispatchSelection} initialSelection={dispatchSelection} /> diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index d2146651..5297feb2 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -233,6 +233,11 @@ export default function ChannelConversationScreen() { [router, channelId] ); + // Stable identity so the memoized MessageBubble isn't re-rendered by a fresh inline handler per item. + const handleRetry = useCallback((m: ChatMessageResultData) => { + if (m.ClientMessageId) void useChatStore.getState().retryOutboxItem(m.ClientMessageId); + }, []); + const renderItem = useCallback( ({ item }: { item: ChatMessageResultData }) => ( m.ClientMessageId && useChatStore.getState().retryOutboxItem(m.ClientMessageId)} + onRetry={handleRetry} onPressImage={setImageUri} /> ), - [currentUserId, showSender, handleToggleReaction, openThread] + [currentUserId, showSender, handleToggleReaction, openThread, handleRetry] ); const keyExtractor = useCallback((item: ChatMessageResultData) => item.ChatMessageId, []); diff --git a/src/app/login/__tests__/login-form.test.tsx b/src/app/login/__tests__/login-form.test.tsx index 9c754788..4ce4e6ee 100644 --- a/src/app/login/__tests__/login-form.test.tsx +++ b/src/app/login/__tests__/login-form.test.tsx @@ -1,353 +1,537 @@ +/** + * Renders the REAL LoginForm. + * + * The previous suite did `jest.mock('../login-form')` and asserted against a hand-written + * stand-in, so the app's authentication entry point had zero coverage. Only the form's + * dependencies are mocked here (i18n, the language hook, the icon set and the keyboard + * module); the react-hook-form + zod wiring under test is the real thing. + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'; -import { View, Text, TouchableOpacity, TextInput } from 'react-native'; +import { Image, Keyboard } from 'react-native'; -// Mock the entire login-form module to replace the schema creation -jest.mock('../login-form', () => { - const React = require('react'); - const { View, Text, TouchableOpacity, TextInput } = require('react-native'); - - const MockLoginForm = ({ onSubmit = () => { }, isLoading = false, error = undefined, onServerUrlPress }: any) => { - const [username, setUsername] = React.useState(''); - const [password, setPassword] = React.useState(''); - const [showPassword, setShowPassword] = React.useState(false); - - const handleSubmit = () => { - onSubmit({ username, password }); - }; - - return ( - - Username - - Password - - - setShowPassword(!showPassword)} - > - {showPassword ? 'Hide' : 'Show'} - - - - {isLoading && } - {isLoading ? 'Signing in...' : 'Log in'} - - {onServerUrlPress && ( - - Server URL - - )} - {error && {error}} - - ); - }; +// ── Dependency mocks (must precede the subject import) ─────────────────────── - return { - LoginForm: MockLoginForm, - }; -}); +// The validation assertions below run the real zod schema through the real +// zodResolver. jest-setup.ts used to stub zod globally, which made that +// impossible; that stub has been removed, so no opt-out is needed here. -import { LoginForm } from '../login-form'; - -// Mock react-i18next jest.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'login.username': 'Username', - 'login.password': 'Password', - 'login.username_placeholder': 'Enter username', - 'login.password_placeholder': 'Enter password', - 'login.login_button_loading': 'Signing in...', - 'login.password_incorrect': 'Incorrect password', - 'settings.server_url': 'Server URL', - 'form.required': 'This field is required', - }; - return translations[key] || key; - }, + t: (key: string) => key, + i18n: { language: 'en' }, }), })); -// Mock nativewind +// Mirrors the global nativewind stub in jest-setup.ts (gluestack needs styled/cssInterop +// to stay pass-through) but lets the colour scheme be driven per test. +let mockColorScheme = 'light'; jest.mock('nativewind', () => ({ - styled: jest.fn((Component: any) => Component), - useColorScheme: () => ({ - colorScheme: 'light', - }), + __esModule: true, + styled: jest.fn((Component: unknown) => Component), + vars: jest.fn((v: unknown) => v), + cssInterop: jest.fn((Component: unknown) => Component), + useColorScheme: () => ({ colorScheme: mockColorScheme, get: () => mockColorScheme }), })); -// Mock react-native-keyboard-controller -jest.mock('react-native-keyboard-controller', () => ({ - KeyboardAvoidingView: ({ children }: any) => children, +const mockSetLanguage = jest.fn(); +jest.mock('@/lib', () => ({ + translate: (key: string) => key, + useSelectedLanguage: () => ({ language: 'en', setLanguage: mockSetLanguage }), })); -// Mock react-hook-form -jest.mock('react-hook-form', () => ({ - useForm: () => ({ - control: {}, - handleSubmit: (fn: any) => fn, - formState: { errors: {} }, - }), - Controller: ({ render }: any) => { - const fieldProps = { - field: { - onChange: jest.fn(), - onBlur: jest.fn(), - value: '', - }, - }; - return render(fieldProps); - }, -})); +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const makeIcon = (testID: string) => { + const Icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { testID, ...props, ref })); + Icon.displayName = testID; + return Icon; + }; + return { + AlertTriangle: makeIcon('alert-triangle-icon'), + ChevronDownIcon: makeIcon('chevron-down-icon'), + EyeIcon: makeIcon('eye-icon'), + EyeOffIcon: makeIcon('eye-off-icon'), + Globe: makeIcon('globe-icon'), + ShieldCheck: makeIcon('shield-check-icon'), + }; +}); -// Mock @hookform/resolvers/zod -jest.mock('@hookform/resolvers/zod', () => ({ - zodResolver: () => ({}), -})); +import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'; -// Mock React Native modules to avoid native module issues -jest.mock('react-native', () => { - const RN = jest.requireActual('react-native'); +import { LoginForm } from '../login-form'; - // Create a safe mock for Settings that won't try to access native modules - const mockSettings = { - get: jest.fn(), - set: jest.fn(), - watchKeys: jest.fn(() => ({ remove: jest.fn() })), - }; +const USERNAME_PLACEHOLDER = 'login.username_placeholder'; +const PASSWORD_PLACEHOLDER = 'login.password_placeholder'; +const SUBMIT_LABEL = 'login.login_button'; +/** Accessible names for the icon-only reveal toggle (the i18n mock echoes the key). */ +const SHOW_PASSWORD_LABEL = 'login.show_password'; +const HIDE_PASSWORD_LABEL = 'login.hide_password'; - const mockKeyboard = { - dismiss: jest.fn(), - addListener: jest.fn(() => ({ remove: jest.fn() })), - removeAllListeners: jest.fn(), - removeListener: jest.fn(), - }; +/** Fills both fields and presses Sign In, then waits for the async resolver to settle. */ +const submitWith = async (username: string, password: string) => { + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), username); + fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), password); + fireEvent.press(screen.getByLabelText(SUBMIT_LABEL)); +}; - // Don't spread the entire RN object to avoid including problematic native modules - return { - View: RN.View, - Text: RN.Text, - TextInput: RN.TextInput, - TouchableOpacity: RN.TouchableOpacity, - Image: RN.Image, - ActivityIndicator: RN.ActivityIndicator, - ScrollView: RN.ScrollView, - Platform: RN.Platform, - Dimensions: RN.Dimensions, - StyleSheet: RN.StyleSheet, - Alert: RN.Alert, - Keyboard: mockKeyboard, - Settings: mockSettings, - // Mock TurboModuleRegistry to prevent any native module access - TurboModuleRegistry: { - getEnforcing: jest.fn(() => ({})), - get: jest.fn(() => ({})), - }, - }; -}); +describe('LoginForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockColorScheme = 'light'; + }); -// Mock UI components -jest.mock('@/components/ui', () => { - const React = require('react'); - const { View } = require('react-native'); + describe('rendering', () => { + it('renders the heading, both credential fields and the submit control', () => { + const { unmount } = render(); - return { - View: ({ children, className }: any) => React.createElement(View, { className }, children), - }; -}); + expect(screen.getByText('login.title')).toBeTruthy(); + expect(screen.getByText('login.subtitle')).toBeTruthy(); + expect(screen.getByText('login.username')).toBeTruthy(); + expect(screen.getByText('login.password')).toBeTruthy(); + expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER)).toBeTruthy(); + expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeTruthy(); + expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy(); -jest.mock('@/components/ui/button', () => { - const React = require('react'); - const { TouchableOpacity, Text, ActivityIndicator } = require('react-native'); + unmount(); + }); - return { - Button: ({ children, onPress, className, variant, action }: any) => - React.createElement(TouchableOpacity, { onPress, testID: 'button', className }, children), - ButtonText: ({ children }: any) => React.createElement(Text, {}, children), - ButtonSpinner: ({ color }: any) => React.createElement(ActivityIndicator, { color, testID: 'button-spinner' }), - }; -}); + it('keeps credential fields free of autocapitalisation and autocomplete', () => { + // Capitalising the first letter of a username is a classic "my password stopped + // working" support ticket. + const { unmount } = render(); -jest.mock('@/components/ui/form-control', () => { - const React = require('react'); - const { View, Text } = require('react-native'); + for (const placeholder of [USERNAME_PLACEHOLDER, PASSWORD_PLACEHOLDER]) { + const field = screen.getByPlaceholderText(placeholder); + expect(field.props.autoCapitalize).toBe('none'); + expect(field.props.autoComplete).toBe('off'); + } - return { - FormControl: ({ children, isInvalid, className }: any) => - React.createElement(View, { className, testID: isInvalid ? 'form-control-invalid' : 'form-control' }, children), - FormControlLabel: ({ children }: any) => React.createElement(View, {}, children), - FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children), - FormControlError: ({ children }: any) => React.createElement(View, { testID: 'form-control-error' }, children), - FormControlErrorIcon: ({ as: IconComponent, className }: any) => - React.createElement(View, { testID: 'form-control-error-icon', className }), - FormControlErrorText: ({ children, className }: any) => - React.createElement(Text, { className, testID: 'form-control-error-text' }, children), - }; -}); + unmount(); + }); -jest.mock('@/components/ui/input', () => { - const React = require('react'); - const { View, TextInput, TouchableOpacity } = require('react-native'); + it('swaps the wordmark for the light-on-dark asset in dark mode', () => { + const { unmount } = render(); + const lightLogo = screen.UNSAFE_getByType(Image).props.source; + unmount(); - return { - Input: ({ children }: any) => React.createElement(View, { testID: 'input' }, children), - InputField: ({ value, onChangeText, onBlur, onSubmitEditing, placeholder, type, ...props }: any) => - React.createElement(TextInput, { - value, - onChangeText, - onBlur, - onSubmitEditing, - placeholder, - secureTextEntry: type === 'password', - testID: 'input-field', - ...props - }), - InputSlot: ({ children, onPress }: any) => - React.createElement(TouchableOpacity, { onPress, testID: 'input-slot' }, children), - InputIcon: ({ as: IconComponent }: any) => - React.createElement(View, { testID: 'input-icon' }), - }; -}); + mockColorScheme = 'dark'; + const dark = render(); + const darkLogo = screen.UNSAFE_getByType(Image).props.source; -jest.mock('@/components/ui/text', () => { - const React = require('react'); - const { Text } = require('react-native'); + // A dark-mode login screen showing the dark wordmark is an invisible logo. + expect(darkLogo).not.toEqual(lightLogo); - return { - Text: ({ children, className }: any) => React.createElement(Text, { className }, children), - }; -}); + dark.unmount(); + }); -// Mock lucide icons -jest.mock('lucide-react-native', () => ({ - AlertTriangle: () => null, - EyeIcon: () => null, - EyeOffIcon: () => null, -})); + it('masks the password field by default', () => { + const { unmount } = render(); -// Mock colors -jest.mock('@/constants/colors', () => ({ - light: { - neutral: { - 400: '#9CA3AF', - }, - }, -})); + expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(true); + expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER).props.secureTextEntry).toBeFalsy(); -describe('LoginForm', () => { - const defaultProps = { - onSubmit: jest.fn(), - isLoading: false, - error: undefined, - }; + unmount(); + }); - beforeEach(() => { - jest.clearAllMocks(); - }); + it('reveals and re-masks the password when the reveal toggle is pressed', () => { + // Driven by accessible name rather than icon testID: that is how a screen-reader user + // reaches this control, so the test fails if the label regresses. + const { unmount } = render(); - it('renders all form fields', () => { - render(); + expect(screen.getByTestId('eye-off-icon', { includeHiddenElements: true })).toBeTruthy(); - expect(screen.getByText('Username')).toBeTruthy(); - expect(screen.getByText('Password')).toBeTruthy(); - expect(screen.getByPlaceholderText('Enter username')).toBeTruthy(); - expect(screen.getByPlaceholderText('Enter password')).toBeTruthy(); - expect(screen.getByText('Log in')).toBeTruthy(); - }); + fireEvent.press(screen.getByLabelText(SHOW_PASSWORD_LABEL)); + + expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(false); + expect(screen.getByTestId('eye-icon', { includeHiddenElements: true })).toBeTruthy(); + expect(screen.queryByTestId('eye-off-icon', { includeHiddenElements: true })).toBeNull(); + + fireEvent.press(screen.getByLabelText(HIDE_PASSWORD_LABEL)); + + expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER).props.secureTextEntry).toBe(true); + + unmount(); + }); + + it('names the reveal toggle for assistive tech and flips the name with its state', () => { + // An unlabelled icon-only toggle announces as a bare "button"; the name must also + // describe what the press will do, not what the field currently is. + const { unmount } = render(); + + const toggle = screen.getByLabelText(SHOW_PASSWORD_LABEL); + expect(toggle.props.accessibilityRole).toBe('button'); + expect(screen.queryByLabelText(HIDE_PASSWORD_LABEL)).toBeNull(); + + fireEvent.press(toggle); - it('renders server URL button when onServerUrlPress prop is provided', () => { - const onServerUrlPress = jest.fn(); - render(); + expect(screen.getByLabelText(HIDE_PASSWORD_LABEL)).toBeTruthy(); + expect(screen.queryByLabelText(SHOW_PASSWORD_LABEL)).toBeNull(); - expect(screen.getByText('Server URL')).toBeTruthy(); + unmount(); + }); }); - it('does not render server URL button when onServerUrlPress prop is not provided', () => { - render(); + describe('validation', () => { + it('blocks submission and reports both fields when nothing is entered', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + fireEvent.press(screen.getByLabelText(SUBMIT_LABEL)); + + // These strings come from the real zod schema in login-form.tsx. + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + expect(screen.getByText('Password is required')).toBeTruthy(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('rejects a username shorter than three characters', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('ab', 'correct-horse'); + + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + expect(screen.queryByText('Password is required')).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('accepts a three-character username — the boundary is inclusive', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('abc', 'correct-horse'); - expect(screen.queryByText('Server URL')).toBeNull(); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('Username must be at least 3 characters')).toBeNull(); + + unmount(); + }); + + it('rejects an empty password even when the username is valid', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('responder', ''); + + expect(await screen.findByText('Password is required')).toBeTruthy(); + expect(screen.queryByText('Username must be at least 3 characters')).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('accepts a single-character password — the schema sets no minimum length', async () => { + // Documents the schema as written: only emptiness is rejected. + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('responder', 'x'); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'x' }); + + unmount(); + }); + + it('reports the schema message verbatim, never a serialised ZodError', async () => { + // The controlled fields used to carry their own `rules` whose validate() returned a + // caught ZodError's `.message` — a JSON blob of issue objects. react-hook-form ignores + // field-level rules when a resolver is supplied, so they were dead weight that would + // have gone live (and user-visible) the moment anyone dropped zodResolver. The zod + // schema is now the single source of truth; this pins the message a user actually sees. + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('ab', ''); + + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + expect(screen.getByText('Password is required')).toBeTruthy(); + // A ZodError message serialises its issues, e.g. {"code": "too_small", ...}. + expect(screen.queryByText(/"code"|too_small|invalid_type/)).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('validates the username on its own, not against a placeholder password', async () => { + // The removed username rule parsed {username: value, password: 'placeholder'}, so it + // could never fail on the password and would have masked an empty one. Both fields + // must be reported independently. + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('responder', ''); + + expect(await screen.findByText('Password is required')).toBeTruthy(); + expect(screen.queryByText('Username must be at least 3 characters')).toBeNull(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('clears the error once the offending field is corrected and resubmitted', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('ab', 'correct-horse'); + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder'); + fireEvent.press(screen.getByLabelText(SUBMIT_LABEL)); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(screen.queryByText('Username must be at least 3 characters')).toBeNull(); + + unmount(); + }); }); - it('calls onServerUrlPress when server URL button is pressed', () => { - const onServerUrlPress = jest.fn(); - render(); + describe('submission', () => { + it('hands the entered credentials to onSubmit', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); - const serverUrlButton = screen.getByText('Server URL').parent; - if (serverUrlButton) { - fireEvent.press(serverUrlButton); - expect(onServerUrlPress).toHaveBeenCalledTimes(1); - } + await submitWith('responder', 'correct-horse'); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'correct-horse' }); + + unmount(); + }); + + it('submits from the keyboard return key and dismisses the keyboard first', async () => { + const dismissSpy = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => {}); + const onSubmit = jest.fn(); + const { unmount } = render(); + + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder'); + fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse'); + fireEvent(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'submitEditing'); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'responder', password: 'correct-horse' }); + expect(dismissSpy).toHaveBeenCalled(); + + dismissSpy.mockRestore(); + unmount(); + }); + + it('does not submit from the keyboard when the entry is invalid', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'ab'); + fireEvent(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'submitEditing'); + + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('renders without crashing when no onSubmit handler is supplied', async () => { + // The prop is optional and defaults to a no-op; a valid submit must not throw. + const { unmount } = render(); + + await submitWith('responder', 'correct-horse'); + await waitFor(() => expect(screen.queryByText('Username must be at least 3 characters')).toBeNull()); + + expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy(); + + unmount(); + }); }); - it('shows loading state when isLoading is true', () => { - render(); + describe('in-flight state', () => { + it('swaps the submit control for a spinner and blocks further submits while loading', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + expect(screen.getByText('login.login_button_loading')).toBeTruthy(); + // The pressable submit control is gone entirely while a login is in flight. + expect(screen.queryByLabelText(SUBMIT_LABEL)).toBeNull(); + + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder'); + fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse'); + fireEvent.press(screen.getByText('login.login_button_loading')); + + await act(async () => {}); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); + + it('restores the submit control when loading finishes', () => { + const { unmount } = render(); + + expect(screen.queryByLabelText(SUBMIT_LABEL)).toBeNull(); + + screen.rerender(); + + expect(screen.getByLabelText(SUBMIT_LABEL)).toBeTruthy(); + expect(screen.queryByText('login.login_button_loading')).toBeNull(); - expect(screen.getByTestId('button-spinner')).toBeTruthy(); - expect(screen.getByText('Signing in...')).toBeTruthy(); + unmount(); + }); }); - it('allows user to toggle password visibility', () => { - render(); + describe('error surfacing', () => { + it('shows the failure message handed down from the login attempt', () => { + const { unmount } = render(); + + expect(screen.getByText('Invalid username or password')).toBeTruthy(); + + unmount(); + }); + + it('renders no error text when there is no error', () => { + const { unmount } = render(); + + expect(screen.queryByText('Invalid username or password')).toBeNull(); + + unmount(); + }); + + it('clears the message once the error prop goes away', () => { + const { unmount } = render(); + + screen.rerender(); + + expect(screen.queryByText('Invalid username or password')).toBeNull(); + + unmount(); + }); - const passwordField = screen.getByPlaceholderText('Enter password'); - const toggleButton = screen.getByTestId('input-slot'); + it('surfaces a rejected login through the banner alone, with no field-level duplicate', () => { + // The form has exactly one error surface for a rejected login: the banner fed by the + // `error` prop, which app/login/index.tsx wires to the auth store's failure string. + // A second, field-level "password was incorrect" message used to be rendered behind a + // `validated` flag that had no setter and so could never turn false — dead code that + // read as a live feature. Nothing upstream attributes a failure to a specific field, + // so the banner stays the single honest path. + const { unmount } = render(); - // Initially should be secured - expect(passwordField.props.secureTextEntry).toBe(true); + expect(screen.getByText('Invalid username or password')).toBeTruthy(); + expect(screen.queryByText('login.password_incorrect')).toBeNull(); - // Toggle visibility - fireEvent.press(toggleButton); + unmount(); + }); - // Re-query the password field and verify it's now visible - const updatedPasswordField = screen.getByPlaceholderText('Enter password'); - expect(updatedPasswordField.props.secureTextEntry).toBe(false); + it('keeps the banner and the field validation as separate, non-overlapping surfaces', async () => { + // A server rejection and a client-side schema failure can be on screen at once; neither + // may leak into the other's slot. + const onSubmit = jest.fn(); + const { unmount } = render(); + + await submitWith('ab', ''); + + expect(await screen.findByText('Username must be at least 3 characters')).toBeTruthy(); + expect(screen.getByText('Invalid username or password')).toBeTruthy(); + expect(screen.queryByText('login.password_incorrect')).toBeNull(); + + unmount(); + }); }); - it('calls onSubmit with form data when form is submitted', async () => { - const onSubmit = jest.fn(); - render(); + describe('secondary affordances', () => { + it('renders and wires the server URL button only when a handler is supplied', () => { + const { unmount } = render(); + expect(screen.queryByText('settings.server_url')).toBeNull(); + unmount(); - const usernameField = screen.getByPlaceholderText('Enter username'); - const passwordField = screen.getByPlaceholderText('Enter password'); - const submitButton = screen.getByText('Log in').parent; + const onServerUrlPress = jest.fn(); + const second = render(); - fireEvent.changeText(usernameField, 'testuser'); - fireEvent.changeText(passwordField, 'testpass'); + fireEvent.press(screen.getByText('settings.server_url')); + expect(onServerUrlPress).toHaveBeenCalledTimes(1); - if (submitButton) { - fireEvent.press(submitButton); + second.unmount(); + }); - await waitFor(() => { - expect(onSubmit).toHaveBeenCalledTimes(1); - }); + it('renders and wires the SSO button only when a handler is supplied', () => { + const { unmount } = render(); + expect(screen.queryByText('login.sso_button')).toBeNull(); + expect(screen.queryByTestId('shield-check-icon', { includeHiddenElements: true })).toBeNull(); + unmount(); - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - username: 'testuser', - password: 'testpass' - }) - ); - } + const onSsoPress = jest.fn(); + const second = render(); + + expect(screen.getByTestId('shield-check-icon', { includeHiddenElements: true })).toBeTruthy(); + fireEvent.press(screen.getByText('login.sso_button')); + expect(onSsoPress).toHaveBeenCalledTimes(1); + + second.unmount(); + }); + + it('does not submit the form when a secondary button is pressed', async () => { + const onSubmit = jest.fn(); + const { unmount } = render(); + + // Fill in valid credentials first: with an empty form, validation alone would stop a + // stray submit and the assertion below would pass for the wrong reason. + fireEvent.changeText(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'responder'); + fireEvent.changeText(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'correct-horse'); + + fireEvent.press(screen.getByText('settings.server_url')); + fireEvent.press(screen.getByText('login.sso_button')); + + // handleSubmit resolves asynchronously, so flush before concluding nothing submitted. + await act(async () => {}); + expect(onSubmit).not.toHaveBeenCalled(); + + unmount(); + }); }); - it('disables form when loading', () => { - render(); + describe('language selector', () => { + // The trigger's TextInput is aria-hidden by gluestack, so it needs the hidden-element opt-in. + const getLanguageTrigger = () => screen.getByPlaceholderText('settings.language', { includeHiddenElements: true }); + + it('reflects the currently selected language', () => { + const { unmount } = render(); + + expect(getLanguageTrigger().props.value).toBe('en'); + + unmount(); + }); + + it('offers every supported language and reports the choice back to the language hook', async () => { + const { unmount } = render( + + + + ); - // When loading, the submit button should show loading state - expect(screen.getByTestId('button-spinner')).toBeTruthy(); - expect(screen.queryByText('Signing in...')).toBeTruthy(); + fireEvent.press(getLanguageTrigger()); + + // All ten options the form declares must be reachable. + for (const key of [ + 'settings.english', + 'settings.spanish', + 'settings.swedish', + 'settings.german', + 'settings.greek', + 'settings.french', + 'settings.italian', + 'settings.polish', + 'settings.ukrainian', + 'settings.arabic', + ]) { + expect(await screen.findByText(key)).toBeTruthy(); + } + + fireEvent.press(screen.getByText('settings.spanish')); + + await waitFor(() => expect(mockSetLanguage).toHaveBeenCalledWith('es')); + + unmount(); + }); }); }); diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index cbd14a35..1b9c4cf1 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,5 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, LogIn, ShieldCheck } from 'lucide-react-native'; +import { AlertTriangle, EyeIcon, EyeOffIcon, Globe, ShieldCheck } from 'lucide-react-native'; import { ChevronDownIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React, { useState } from 'react'; @@ -72,15 +72,10 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde const { control, handleSubmit, - getValues, formState: { errors }, } = useForm({ resolver: zodResolver(loginFormSchema), }); - const [validated] = useState({ - usernameValid: true, - passwordValid: true, - }); const [showPassword, setShowPassword] = useState(false); @@ -102,7 +97,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde {/* Username */} - + {t('login.username')} @@ -110,16 +105,6 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde defaultValue="" name="username" control={control} - rules={{ - validate: async (value) => { - try { - await loginFormSchema.parseAsync({ username: value, password: 'placeholder' }); - return true; - } catch (err: any) { - return err.message; - } - }, - }} render={({ field: { onChange, onBlur, value } }) => ( {}, isLoading = false, error = unde {/* Password form */} - + {t('login.password')} @@ -150,16 +135,6 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde defaultValue="" name="password" control={control} - rules={{ - validate: async (value) => { - try { - await loginFormSchema.parseAsync({ username: getValues('username'), password: value }); - return true; - } catch (err: any) { - return err.message; - } - }, - }} render={({ field: { onChange, onBlur, value } }) => ( {}, isLoading = false, error = unde autoCapitalize="none" autoComplete="off" /> - + {/* gluestack's Slot defaults to accessibilityElementsHidden, which would hide this + control from VoiceOver even once labelled — an icon-only toggle with no other + way to reach it. Re-expose it explicitly. */} + @@ -181,7 +166,7 @@ export const LoginForm = ({ onSubmit = () => {}, isLoading = false, error = unde /> - {errors?.password?.message || (!validated.passwordValid && t('login.password_incorrect'))} + {errors?.password?.message} diff --git a/src/app/maps/indoor/[id].tsx b/src/app/maps/indoor/[id].tsx index be338243..116382a1 100644 --- a/src/app/maps/indoor/[id].tsx +++ b/src/app/maps/indoor/[id].tsx @@ -153,63 +153,66 @@ export default function IndoorMapViewer() { {/* Map */} + {/* The map stays mounted across floor switches — unmounting it tore down + and rebuilt the whole GL context on every tab tap. The loader is + overlaid instead. */} + + + + {/* Floor plan image overlay */} + {floorImageUrl && currentFloor && currentIndoorMap.BoundsNELatitude ? ( + + + + ) : null} + + {/* Zone polygons */} + {filteredZonesGeoJSON ? ( + + + + + + ) : null} + + {isLoadingGeoJSON ? ( - + - ) : ( - - - - {/* Floor plan image overlay */} - {floorImageUrl && currentFloor && currentIndoorMap.BoundsNELatitude ? ( - - - - ) : null} - - {/* Zone polygons */} - {filteredZonesGeoJSON ? ( - - - - - - ) : null} - - )} + ) : null} {/* Zone detail popover */} {selectedZone ? ( diff --git a/src/app/routes/directions.tsx b/src/app/routes/directions.tsx index 74d8413b..b9600320 100644 --- a/src/app/routes/directions.tsx +++ b/src/app/routes/directions.tsx @@ -107,10 +107,10 @@ const congestionColor = (level: string): string => { } }; -/** Derive a human-readable driving condition summary from congestion data */ -const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: string; color: string; icon: typeof TrafficConeIcon } => { +/** Derive a driving condition summary from congestion data; labelKey is an i18n key resolved at render time */ +export const deriveDrivingCondition = (congestion: CongestionSegment[]): { labelKey: string; color: string; icon: typeof TrafficConeIcon } => { if (congestion.length === 0) { - return { label: 'No traffic data', color: '#9ca3af', icon: TrafficConeIcon }; + return { labelKey: 'routes.traffic_no_data', color: '#9ca3af', icon: TrafficConeIcon }; } const counts = { low: 0, moderate: 0, heavy: 0, severe: 0 }; @@ -120,15 +120,15 @@ const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: strin const total = congestion.length; if (counts.severe / total > 0.15) { - return { label: 'Severe traffic', color: '#ef4444', icon: AlertTriangleIcon }; + return { labelKey: 'routes.traffic_severe', color: '#ef4444', icon: AlertTriangleIcon }; } if (counts.heavy / total > 0.2) { - return { label: 'Heavy traffic', color: '#f97316', icon: AlertTriangleIcon }; + return { labelKey: 'routes.traffic_heavy', color: '#f97316', icon: AlertTriangleIcon }; } if (counts.moderate / total > 0.3) { - return { label: 'Moderate traffic', color: '#eab308', icon: TrafficConeIcon }; + return { labelKey: 'routes.traffic_moderate', color: '#eab308', icon: TrafficConeIcon }; } - return { label: 'Light traffic', color: '#22c55e', icon: TrafficConeIcon }; + return { labelKey: 'routes.traffic_light', color: '#22c55e', icon: TrafficConeIcon }; }; // --------------------------------------------------------------------------- @@ -143,7 +143,7 @@ const deriveDrivingCondition = (congestion: CongestionSegment[]): { label: strin * Returns parsed route geometry, durations, and congestion data, or null * on failure (e.g., missing API key, network error). */ -async function fetchMapboxDirections(waypoints: [number, number][]): Promise { +export async function fetchMapboxDirections(waypoints: [number, number][], language: string = 'en'): Promise { if (waypoints.length < 2) return null; const token = Env.UNIT_MAPBOX_PUBKEY; @@ -154,7 +154,14 @@ async function fetchMapboxDirections(waypoints: [number, number][]): Promise `${lng},${lat}`).join(';'); const url = - `${MAPBOX_DIRECTIONS_API}/${coords}` + `?access_token=${token}` + `&geometries=geojson` + `&overview=full` + `&annotations=congestion,duration,distance` + `&steps=true` + `&continue_straight=true` + `&language=en`; + `${MAPBOX_DIRECTIONS_API}/${coords}` + + `?access_token=${token}` + + `&geometries=geojson` + + `&overview=full` + + `&annotations=congestion,duration,distance` + + `&steps=true` + + `&continue_straight=true` + + `&language=${encodeURIComponent(language)}`; try { const response = await fetch(url); @@ -371,7 +378,7 @@ const markerStyles = StyleSheet.create({ // --------------------------------------------------------------------------- export default function RouteDirectionsScreen() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { instanceId } = useLocalSearchParams<{ instanceId: string }>(); const { colorScheme } = useColorScheme(); const cameraRef = useRef(null); @@ -416,6 +423,9 @@ export default function RouteDirectionsScreen() { return validStops.slice(1, -1); }, [validStops]); + // Directions in the user's locale (Mapbox expects a primary language subtag, e.g. "en" from "en-US") + const directionsLanguage = i18n.language?.split('-')[0] || 'en'; + // Fetch real driving directions from Mapbox API useEffect(() => { if (validStops.length < 2) return; @@ -426,7 +436,7 @@ export default function RouteDirectionsScreen() { setIsFetchingDirections(true); const waypoints: [number, number][] = validStops.map((s) => [s.Longitude, s.Latitude]); - const result = await fetchMapboxDirections(waypoints); + const result = await fetchMapboxDirections(waypoints, directionsLanguage); if (!cancelled) { setMapboxDirections(result); @@ -439,7 +449,7 @@ export default function RouteDirectionsScreen() { return () => { cancelled = true; }; - }, [validStops]); + }, [validStops, directionsLanguage]); // Build the route GeoJSON: prefer Mapbox directions, fallback to backend, then straight-line const routeGeoJson = useMemo((): GeoJSON.Feature | null => { @@ -743,7 +753,7 @@ export default function RouteDirectionsScreen() { - {drivingCondition.label} + {t(drivingCondition.labelKey)} {trafficDelaySeconds != null && trafficDelaySeconds > 0 ? ( diff --git a/src/components/calls/__tests__/call-card.test.tsx b/src/components/calls/__tests__/call-card.test.tsx new file mode 100644 index 00000000..046a0dfe --- /dev/null +++ b/src/components/calls/__tests__/call-card.test.tsx @@ -0,0 +1,77 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { CallCard } from '../call-card'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// If the card ever reintroduces the WebView-backed renderer, this mock makes it +// visible in the tree so the assertions below fail loudly. +jest.mock('@/components/ui/html-renderer', () => { + const { View } = require('react-native'); + return { + HtmlRenderer: () => , + }; +}); + +const baseCall = { + CallId: '42', + Number: '2024-042', + Name: 'Structure Fire', + Address: '1 Main St', + Nature: '

Heavy smoke & flames showing

', + Priority: 1, + LoggedOnUtc: new Date().toISOString(), +} as never; + +const priority = { Id: 1, Name: 'High', Color: '#ff0000' } as never; + +describe('CallCard', () => { + it('renders the call nature as plain text, not a WebView', () => { + const { queryByTestId, getByText, unmount } = render(); + + // A WebView per FlashList row is far too heavy — the card must render text. + expect(queryByTestId('html-renderer')).toBeNull(); + expect(getByText('Heavy smoke & flames showing')).toBeTruthy(); + + unmount(); + }); + + it('limits the nature preview to a few lines', () => { + const { getByText, unmount } = render(); + + expect(getByText('Heavy smoke & flames showing').props.numberOfLines).toBe(4); + + unmount(); + }); + + it('renders no nature block when the call has no nature', () => { + const { queryByTestId, unmount } = render(); + + expect(queryByTestId('html-renderer')).toBeNull(); + + unmount(); + }); + + it('renders a nature that is only markup as no nature block', () => { + const { queryByText, unmount } = render(

' } as never} priority={priority} />); + + expect(queryByText('

')).toBeNull(); + + unmount(); + }); + + it('still renders the core call fields', () => { + const { getByText, unmount } = render(); + + expect(getByText('#2024-042')).toBeTruthy(); + expect(getByText('Structure Fire')).toBeTruthy(); + expect(getByText('1 Main St')).toBeTruthy(); + + unmount(); + }); +}); diff --git a/src/components/calls/__tests__/call-images-modal.test.tsx b/src/components/calls/__tests__/call-images-modal.test.tsx index d1cc8edc..343fda1a 100644 --- a/src/components/calls/__tests__/call-images-modal.test.tsx +++ b/src/components/calls/__tests__/call-images-modal.test.tsx @@ -1,26 +1,37 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; -import { useAuthStore } from '@/lib'; -import { useCallDetailStore } from '@/stores/calls/detail-store'; -import { useLocationStore } from '@/stores/app/location-store'; -import { useAnalytics } from '@/hooks/use-analytics'; - -// Mock dependencies +/** + * Exercises the real CallImagesModal. Everything mocked here is a dependency of the + * component (stores, expo modules, the child full-screen modal, native plumbing); the + * component under test is imported and rendered for real. + */ +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/stores/calls/detail-store', () => ({ + useCallDetailStore: (selector: any) => (selector ? selector(mockDetailState) : mockDetailState), +})); + +jest.mock('@/stores/app/location-store', () => ({ + useLocationStore: (selector: any) => (selector ? selector(mockLocationState) : mockLocationState), +})); + jest.mock('@/lib', () => ({ - useAuthStore: { - getState: jest.fn(), - }, + useAuthStore: { getState: () => ({ userId: mockUserId }) }, })); -jest.mock('@/stores/calls/detail-store'); -jest.mock('@/stores/app/location-store'); +jest.mock('@/stores/toast/store', () => ({ + useToastStore: (selector: any) => (selector ? selector(mockToastState) : mockToastState), +})); -jest.mock('@/hooks/use-analytics'); +jest.mock('@/lib/logging', () => ({ + logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); -jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), +jest.mock('@/hooks/use-analytics', () => ({ + useAnalytics: () => ({ trackEvent: mockTrackEvent }), })); jest.mock('expo-image-picker', () => ({ @@ -28,1187 +39,789 @@ jest.mock('expo-image-picker', () => ({ requestCameraPermissionsAsync: jest.fn(), launchImageLibraryAsync: jest.fn(), launchCameraAsync: jest.fn(), - MediaTypeOptions: { - Images: 'Images', - }, -})); - -jest.mock('expo-file-system/legacy', () => ({ - readAsStringAsync: jest.fn(), - EncodingType: { - Base64: 'base64', - }, + CameraType: { back: 'back', front: 'front' }, })); jest.mock('expo-image-manipulator', () => ({ manipulateAsync: jest.fn(), - SaveFormat: { - PNG: 'png', - }, + SaveFormat: { PNG: 'png', JPEG: 'jpeg' }, })); -// Create MockCallImagesModal to avoid CSS interop issues -interface CallImagesModalProps { - isOpen: boolean; - onClose: () => void; - callId: string; -} - -const MockCallImagesModal: React.FC = ({ isOpen, onClose, callId }) => { - const [activeIndex, setActiveIndex] = useState(0); - const [imageErrors, setImageErrors] = useState>(new Set()); - const [fullScreenImage, setFullScreenImage] = useState<{ uri: string; name?: string } | null>(null); - - const { callImages, isLoadingImages, errorImages, fetchCallImages, uploadCallImage } = useCallDetailStore(); - const { trackEvent } = useAnalytics(); - const { latitude, longitude } = useLocationStore(); - - // Filter valid images and memoize to prevent re-filtering on every render - const validImages = useMemo(() => { - if (!callImages) return []; - return callImages.filter((item) => item && (item.Data?.trim() || item.Url?.trim())); - }, [callImages]); - - useEffect(() => { - if (isOpen && callId) { - fetchCallImages(callId); - setActiveIndex(0); - setImageErrors(new Set()); - } - }, [isOpen, callId, fetchCallImages]); - - // Track when call images modal is opened/rendered - useEffect(() => { - if (isOpen) { - trackEvent('call_images_modal_opened', { - callId: callId, - hasExistingImages: validImages.length > 0, - imagesCount: validImages.length, - isLoadingImages: isLoadingImages, - hasError: !!errorImages, - }); - } - }, [isOpen, trackEvent, callId, validImages.length, isLoadingImages, errorImages]); - - // Reset active index when valid images change - useEffect(() => { - if (activeIndex >= validImages.length && validImages.length > 0) { - setActiveIndex(0); - } - }, [validImages.length, activeIndex]); - - const handleNext = () => { - setActiveIndex(Math.min(validImages.length - 1, activeIndex + 1)); +jest.mock('expo-file-system/legacy', () => ({ + readAsStringAsync: jest.fn(), + EncodingType: { Base64: 'base64' }, +})); + +// expo-image sets no testID of its own; key the stub off the production `recyclingKey` +// (the gallery sets it per item) so a specific rendered image can be addressed. +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Image: (props: any) => React.createElement(View, { testID: props.recyclingKey ? `expo-image-${props.recyclingKey}` : 'expo-image', ...props }), }; +}); + +jest.mock('react-native-keyboard-controller', () => { + const React = require('react'); + const { View } = require('react-native'); + return { KeyboardStickyView: ({ children, ...props }: any) => React.createElement(View, props, children) }; +}); - const handlePrevious = () => { - setActiveIndex(Math.max(0, activeIndex - 1)); +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref })); + return new Proxy({}, { get: () => icon }); +}); + +// Stub for the child modal (covered by full-screen-image-modal.test.tsx) that surfaces +// what this component hands it. +jest.mock('../full-screen-image-modal', () => { + const React = require('react'); + const { Pressable, View } = require('react-native'); + return { + __esModule: true, + default: ({ isOpen, onClose, imageSource, imageName }: any) => + isOpen ? React.createElement(View, { testID: 'full-screen-modal', imageSource, imageName }, React.createElement(Pressable, { testID: 'full-screen-close', onPress: onClose })) : null, }; +}); - if (!isOpen) return null; - - // Mock React Native components for testing - const View = (props: any) => React.createElement('div', { testID: props.testID, ...props }); - const Text = (props: any) => React.createElement('span', { testID: props.testID, ...props }); - const TouchableOpacity = (props: any) => React.createElement('button', { - testID: props.testID, - onPress: props.onPress, - onClick: props.onPress, - disabled: props.disabled, - ...props - }); - const Image = (props: any) => React.createElement('img', { - testID: props.testID, - src: props.source?.uri, - alt: props.alt, - onError: props.onError, - onLoad: props.onLoad, - ...props - }); +import { readAsStringAsync } from 'expo-file-system/legacy'; +import * as ImageManipulator from 'expo-image-manipulator'; +import * as ImagePicker from 'expo-image-picker'; + +import { logger } from '@/lib/logging'; +import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; + +import CallImagesModal from '../call-images-modal'; - if (isLoadingImages) { - return React.createElement(View, { testID: 'actionsheet' }, - React.createElement(View, { testID: 'loading' }, 'Loading...') - ); - } - - if (errorImages) { - return React.createElement(View, { testID: 'actionsheet' }, - React.createElement(View, { testID: 'error-state' }, [ - React.createElement(View, { testID: 'heading', key: 'heading' }, 'Error'), - React.createElement(View, { testID: 'description', key: 'description' }, errorImages) - ]) - ); - } - - if (validImages.length === 0) { - return React.createElement(View, { testID: 'actionsheet' }, - React.createElement(View, { testID: 'zero-state' }, [ - React.createElement(View, { testID: 'heading', key: 'heading' }, 'No images'), - React.createElement(View, { testID: 'description', key: 'description' }, 'No images available') - ]) - ); - } - - return React.createElement(View, { testID: 'actionsheet' }, - React.createElement(View, { testID: 'actionsheet-backdrop' }, - React.createElement(View, { testID: 'actionsheet-content' }, [ - React.createElement(View, { testID: 'drag-indicator-wrapper', key: 'drag-wrapper' }, - React.createElement(View, { testID: 'drag-indicator' }) - ), - React.createElement(View, { testID: 'pagination', key: 'pagination' }, - validImages.length > 0 ? `${activeIndex + 1} / ${validImages.length}` : '' - ), - React.createElement(View, { testID: 'flatlist', key: 'flatlist' }, - validImages.map((item, index) => { - const hasError = imageErrors.has(item.Id); - let imageSource: { uri: string } | null = null; - - if (item.Data && item.Data.trim() !== '') { - const mimeType = item.Mime || 'image/png'; - imageSource = { uri: `data:${mimeType};base64,${item.Data}` }; - } else if (item.Url && item.Url.trim() !== '') { - imageSource = { uri: item.Url }; - } - - if (!imageSource || hasError) { - return React.createElement(View, { - testID: `image-error-${item.Id}`, - key: item.Id - }, [ - React.createElement(Text, { key: 'error-text' }, 'callImages.failed_to_load'), - React.createElement(View, { key: 'name' }, item.Name || ''), - React.createElement(View, { key: 'timestamp' }, item.Timestamp || '') - ]); - } - - return React.createElement(View, { - testID: `image-${item.Id}`, - key: item.Id - }, [ - React.createElement(TouchableOpacity, { - testID: `image-${item.Id}-touchable`, - key: 'touchable', - onPress: () => setFullScreenImage({ uri: imageSource!.uri, name: item.Name }) - }, - React.createElement(Image, { - key: 'image', - source: imageSource, - alt: item.Name, - onError: () => { - setImageErrors((prev) => new Set([...prev, item.Id])); - }, - onLoad: () => { - setImageErrors((prev) => { - const newSet = new Set(prev); - newSet.delete(item.Id); - return newSet; - }); - } - }) - ), - React.createElement(View, { key: 'name' }, item.Name || ''), - React.createElement(View, { key: 'timestamp' }, item.Timestamp || '') - ]); - }) - ), - React.createElement(View, { testID: 'navigation', key: 'navigation' }, [ - React.createElement(TouchableOpacity, { - testID: 'previous-button', - key: 'previous', - onPress: handlePrevious, - disabled: activeIndex === 0 - }, 'Previous'), - React.createElement(TouchableOpacity, { - testID: 'next-button', - key: 'next', - onPress: handleNext, - disabled: activeIndex === validImages.length - 1 - }, 'Next') - ]), - React.createElement(TouchableOpacity, { - testID: 'close-button', - key: 'close', - onPress: onClose - }, 'Close'), - fullScreenImage && React.createElement(View, { - testID: 'full-screen-modal', - key: 'full-screen-modal' - }, [ - React.createElement(TouchableOpacity, { - testID: 'full-screen-close-button', - key: 'full-screen-close', - onPress: () => setFullScreenImage(null) - }, 'Close Full Screen'), - React.createElement(Image, { - testID: 'full-screen-image', - key: 'full-screen-image', - source: { uri: fullScreenImage.uri }, - alt: fullScreenImage.name || 'Full screen image' - }) - ]) - ]) - ) - ); +const mockFetchCallImages = jest.fn(); +const mockUploadCallImage = jest.fn(); +const mockClearImages = jest.fn(); +const mockTrackEvent = jest.fn(); +let mockUserId: string | null = 'user-42'; + +const buildImage = (overrides: Partial): CallFileResultData => + ({ + Id: 'img-1', + CallId: 'call-1', + Type: 2, + Name: 'Image One', + Size: 100, + Url: '', + Data: '', + UserId: 'user-42', + Timestamp: '2024-05-01 08:00', + Mime: 'image/png', + FileName: 'one.png', + ...overrides, + }) as CallFileResultData; + +const base64Image = buildImage({ Id: 'img-1', Name: 'Front of structure', Data: 'AAAABBBB', Mime: 'image/jpeg', Timestamp: '2024-05-01 08:00' }); +const urlImage = buildImage({ Id: 'img-2', Name: 'Side alpha', Url: ' https://example.com/side-a.png ', Timestamp: '2024-05-01 08:05' }); +const blankImage = buildImage({ Id: 'img-3', Name: 'Nothing at all', Data: ' ', Url: ' ' }); + +// One object that is mutated in place — never reassigned — so the selector mock stays live. +const mockDetailState = { + callImages: [base64Image, urlImage] as CallFileResultData[] | null, + isLoadingImages: false, + errorImages: null as string | null, + fetchCallImages: mockFetchCallImages, + uploadCallImage: mockUploadCallImage, + clearImages: mockClearImages, }; -// Mock the actual component -jest.mock('../call-images-modal', () => ({ - __esModule: true, - default: MockCallImagesModal, -})); +const mockLocationState = { + latitude: 40.1 as number | null, + longitude: -75.2 as number | null, +}; -const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction; -const mockUseLocationStore = useLocationStore as jest.MockedFunction; -const mockUseAuthStore = useAuthStore as jest.MockedObject; -const mockUseAnalytics = useAnalytics as jest.MockedFunction; +const mockShowToast = jest.fn(); +const mockToastState = { showToast: mockShowToast }; -const mockTrackEvent = jest.fn(); +const setDetailState = (updates: Partial) => Object.assign(mockDetailState, updates); -// Mock expo modules -const mockReadAsStringAsync = jest.fn(); -const mockManipulateAsync = jest.fn(); +const defaultProps = { isOpen: true, onClose: jest.fn(), callId: 'call-1' }; -jest.mock('expo-file-system/legacy', () => ({ - readAsStringAsync: mockReadAsStringAsync, - EncodingType: { - Base64: 'base64', - }, -})); +const mockedPicker = ImagePicker as jest.Mocked; +const mockManipulateAsync = ImageManipulator.manipulateAsync as jest.Mock; +const mockReadAsStringAsync = readAsStringAsync as jest.Mock; +const mockAlert = jest.fn(); -jest.mock('expo-image-manipulator', () => ({ - manipulateAsync: mockManipulateAsync, - SaveFormat: { - PNG: 'png', - }, -})); +/** Walks from the "Add" chooser to a picked-image preview ready to upload. */ +const pickImageFromLibrary = async (screen: ReturnType, asset: { uri: string; fileName?: string } = { uri: 'file:///tmp/original.jpg', fileName: 'original.jpg' }) => { + mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchImageLibraryAsync.mockResolvedValue({ canceled: false, assets: [asset] } as never); -describe('CallImagesModal', () => { - const defaultProps = { - isOpen: true, - onClose: jest.fn(), - callId: 'test-call-id', - }; + fireEvent.press(screen.getByText('callImages.add')); + fireEvent.press(screen.getByText('callImages.select_from_gallery')); - const mockCallImages = [ - { - Id: '1', - Name: 'Image 1', - Data: 'base64data1', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-01', - }, - { - Id: '2', - Name: 'Image 2', - Data: '', - Url: 'https://example.com/image2.jpg', - Mime: 'image/jpeg', - Timestamp: '2023-01-02', - }, - { - Id: '3', - Name: 'Invalid Image', - Data: '', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-03', - }, - { - Id: '4', - Name: 'Image 4', - Data: 'base64data4', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-04', - }, - { - Id: '5', - Name: 'Image 5', - Data: 'base64data5', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-05', - }, - ]; - - const mockStore = { - callImages: mockCallImages, - isLoadingImages: false, - errorImages: null, - fetchCallImages: jest.fn(), - uploadCallImage: jest.fn(), - }; + await waitFor(() => expect(screen.getByTestId('image-note-input')).toBeTruthy()); +}; +describe('CallImagesModal', () => { beforeEach(() => { jest.clearAllMocks(); - mockReadAsStringAsync.mockClear(); - mockManipulateAsync.mockClear(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockStore as any) : mockStore as any); - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - latitude: 40.7128, - longitude: -74.0060, - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - isBackgroundEnabled: false, - isMapLocked: false, - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - setMapLocked: jest.fn(), - }) : { - latitude: 40.7128, - longitude: -74.0060, - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - isBackgroundEnabled: false, - isMapLocked: false, - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - setMapLocked: jest.fn(), - }); - mockUseAnalytics.mockReturnValue({ - trackEvent: mockTrackEvent, - }); - mockUseAuthStore.getState.mockReturnValue({ - userId: 'test-user-id', - accessToken: 'test-token', - refreshToken: 'test-refresh-token', - refreshTokenExpiresOn: new Date(), - status: 'authenticated', - user: null, - departmentId: 'test-dept-id', - groupIds: [], - userName: 'test-user', - signIn: jest.fn(), - signOut: jest.fn(), - setUser: jest.fn(), - setDepartmentId: jest.fn(), - setGroupIds: jest.fn(), - } as any); + setDetailState({ + callImages: [base64Image, urlImage], + isLoadingImages: false, + errorImages: null, + }); + mockLocationState.latitude = 40.1; + mockLocationState.longitude = -75.2; + mockUserId = 'user-42'; + mockUploadCallImage.mockResolvedValue(undefined); + mockManipulateAsync.mockResolvedValue({ uri: 'file:///tmp/manipulated.png', width: 1024, height: 768 }); + mockReadAsStringAsync.mockResolvedValue('BASE64PAYLOAD'); + (global as unknown as { alert: jest.Mock }).alert = mockAlert; }); - describe('CSS Interop Fix - Basic Functionality', () => { - it('renders correctly when open', () => { - const { getByTestId } = render(); - expect(getByTestId('actionsheet')).toBeTruthy(); - }); + describe('opening and closing', () => { + it('renders nothing and fetches nothing while closed', () => { + const { queryByText, unmount } = render(); - it('does not render when closed', () => { - const { queryByTestId } = render(); - expect(queryByTestId('actionsheet')).toBeFalsy(); - }); + expect(queryByText('callImages.title')).toBeNull(); + expect(mockFetchCallImages).not.toHaveBeenCalled(); - it('fetches images when opened', () => { - render(); - expect(mockStore.fetchCallImages).toHaveBeenCalledWith('test-call-id'); + unmount(); }); - it('shows loading state', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - isLoadingImages: true, - } as any) : { - ...mockStore, - isLoadingImages: true, - } as any); - - const { getByTestId } = render(); - expect(getByTestId('loading')).toBeTruthy(); + it('fetches the images for the call it was opened with', () => { + const { getByText, unmount } = render(); + + expect(mockFetchCallImages).toHaveBeenCalledWith('call-1'); + expect(getByText('callImages.title')).toBeTruthy(); + + unmount(); }); - it('shows error state', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - errorImages: 'Failed to load images', - } as any) : { - ...mockStore, - errorImages: 'Failed to load images', - } as any); - - const { getByTestId } = render(); - expect(getByTestId('error-state')).toBeTruthy(); + it('refetches when it is pointed at a different call', () => { + const { rerender, unmount } = render(); + + rerender(); + + expect(mockFetchCallImages).toHaveBeenCalledWith('call-9'); + + unmount(); }); - it('shows zero state when no images', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - callImages: [], - } as any) : { - ...mockStore, - callImages: [], - } as any); - - const { getByTestId } = render(); - expect(getByTestId('zero-state')).toBeTruthy(); + it('drops the loaded images from the store when it closes', () => { + const { rerender, unmount } = render(); + + mockClearImages.mockClear(); + rerender(); + + expect(mockClearImages).toHaveBeenCalled(); + + unmount(); }); - it('filters out invalid images from pagination', () => { - const { getByTestId } = render(); - const pagination = getByTestId('pagination'); - expect(pagination).toHaveTextContent('1 / 4'); // 4 valid images (filtering out the one with no data or URL) + it('closes when the close button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); + + fireEvent.press(getByTestId('close-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); }); }); - describe('Component Behavior', () => { - it('handles pagination correctly', () => { - const { getByTestId } = render(); + describe('gallery', () => { + it('shows the first image with its name and timestamp', () => { + const { getByText, unmount } = render(); - // Should start at first image - expect(getByTestId('pagination')).toHaveTextContent('1 / 4'); + expect(getByText('Front of structure')).toBeTruthy(); + expect(getByText('2024-05-01 08:00')).toBeTruthy(); - // Click next button - const nextButton = getByTestId('next-button'); - fireEvent.press(nextButton); - - // Should move to second image - need to re-render to see state change - expect(getByTestId('pagination')).toHaveTextContent('2 / 4'); + unmount(); }); - it('handles image loading errors gracefully', () => { - // Test that images with invalid data show error state - const invalidImagesStore = { - ...mockStore, - callImages: [ - { - Id: '1', - Name: 'Valid Image', - Data: 'base64data1', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-01', - }, - { - Id: '2', - Name: 'Invalid Image', - Data: '', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-02', - } - ] - }; - - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any); - - const { getByTestId, queryByTestId } = render(); - - // Should have valid image - expect(getByTestId('image-1')).toBeTruthy(); - // Should not have invalid image in gallery (filtered out) - expect(queryByTestId('image-2')).toBeFalsy(); + it('builds a data URI from the stored payload using the image mime type', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('expo-image-img-1').props.source).toEqual({ uri: 'data:image/jpeg;base64,AAAABBBB' }); + + unmount(); }); - it('calls onClose when close button clicked', () => { - const mockOnClose = jest.fn(); - const { getByTestId } = render(); + it('falls back to the remote URL, trimmed, when there is no payload', () => { + const { getByTestId, unmount } = render(); - const closeButton = getByTestId('close-button'); - fireEvent.press(closeButton); + fireEvent.press(getByTestId('next-button')); - expect(mockOnClose).toHaveBeenCalled(); + expect(getByTestId('expo-image-img-2').props.source).toEqual({ uri: 'https://example.com/side-a.png' }); + + unmount(); }); - }); - describe('Logic Tests', () => { - it('should filter valid images correctly', () => { - const mockImages = [ - { Id: '1', Data: 'base64data', Url: '', Name: 'Valid Image 1' }, - { Id: '2', Data: '', Url: 'https://example.com/image.jpg', Name: 'Valid Image 2' }, - { Id: '3', Data: '', Url: '', Name: 'Invalid Image' }, - { Id: '4', Data: 'base64data2', Url: '', Name: 'Valid Image 3' }, - ]; + it('ignores images that carry neither a payload nor a URL', () => { + setDetailState({ callImages: [base64Image, blankImage, urlImage] }); + const { getByText, queryByText, unmount } = render(); - const validImages = mockImages.filter((item) => item && (item.Data?.trim() || item.Url?.trim())); + expect(getByText('1 / 2')).toBeTruthy(); + expect(queryByText('Nothing at all')).toBeNull(); - expect(validImages).toHaveLength(3); - expect(validImages.map(img => img.Id)).toEqual(['1', '2', '4']); + unmount(); }); - it('should prefer Data over Url when both are available', () => { - const mockImage = { - Id: '1', - Data: 'base64data', - Url: 'https://example.com/fallback.jpg', - Mime: 'image/png', - Name: 'Test Image' - }; - - let imageSource = null; - if (mockImage.Data && mockImage.Data.trim() !== '') { - const mimeType = mockImage.Mime || 'image/png'; - imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` }; - } else if (mockImage.Url && mockImage.Url.trim() !== '') { - imageSource = { uri: mockImage.Url }; - } - - expect(imageSource).toEqual({ - uri: 'data:image/png;base64,base64data' - }); + it('pages forward and back through the images', () => { + const { getByTestId, getByText, unmount } = render(); + + expect(getByText('1 / 2')).toBeTruthy(); + + fireEvent.press(getByTestId('next-button')); + expect(getByText('Side alpha')).toBeTruthy(); + expect(getByText('2 / 2')).toBeTruthy(); + + fireEvent.press(getByTestId('previous-button')); + expect(getByText('Front of structure')).toBeTruthy(); + expect(getByText('1 / 2')).toBeTruthy(); + + unmount(); }); - it('should fall back to URL when Data is empty', () => { - const mockImage = { - Id: '2', - Data: '', - Url: 'https://example.com/image.jpg', - Mime: 'image/jpeg', - Name: 'Test Image' - }; - - let imageSource = null; - if (mockImage.Data && mockImage.Data.trim() !== '') { - const mimeType = mockImage.Mime || 'image/png'; - imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` }; - } else if (mockImage.Url && mockImage.Url.trim() !== '') { - imageSource = { uri: mockImage.Url }; - } - - expect(imageSource).toEqual({ - uri: 'https://example.com/image.jpg' - }); + it('stops at the last image instead of paging off the end', () => { + const { getByTestId, getByText, unmount } = render(); + + fireEvent.press(getByTestId('next-button')); + fireEvent.press(getByTestId('next-button')); + + expect(getByText('2 / 2')).toBeTruthy(); + expect(getByText('Side alpha')).toBeTruthy(); + + unmount(); }); - it('should return null when both Data and Url are empty', () => { - const mockImage = { - Id: '3', - Data: '', - Url: '', - Mime: 'image/png', - Name: 'Invalid Image' - }; - - let imageSource = null; - if (mockImage.Data && mockImage.Data.trim() !== '') { - const mimeType = mockImage.Mime || 'image/png'; - imageSource = { uri: `data:${mimeType};base64,${mockImage.Data}` }; - } else if (mockImage.Url && mockImage.Url.trim() !== '') { - imageSource = { uri: mockImage.Url }; - } - - expect(imageSource).toBeNull(); + it('stops at the first image instead of paging before the start', () => { + const { getByTestId, getByText, unmount } = render(); + + fireEvent.press(getByTestId('previous-button')); + + expect(getByText('1 / 2')).toBeTruthy(); + + unmount(); }); - it('should handle pagination bounds correctly', () => { - const validImagesLength = 5; - let activeIndex = 0; + it('hides the pager when there is only one image', () => { + setDetailState({ callImages: [base64Image] }); + const { queryByTestId, unmount } = render(); + + expect(queryByTestId('next-button')).toBeNull(); + expect(queryByTestId('previous-button')).toBeNull(); - const handleNext = () => { - return Math.min(validImagesLength - 1, activeIndex + 1); - }; + unmount(); + }); - const handlePrevious = () => { - return Math.max(0, activeIndex - 1); - }; + it('swaps in a placeholder when an image fails to decode', () => { + const { getByTestId, getByText, queryByTestId, unmount } = render(); - // Test at start - expect(handlePrevious()).toBe(0); - expect(handleNext()).toBe(1); + fireEvent(getByTestId('expo-image-img-1'), 'error'); - // Test in middle - activeIndex = 2; - expect(handlePrevious()).toBe(1); - expect(handleNext()).toBe(3); + expect(getByText('callImages.failed_to_load')).toBeTruthy(); + expect(queryByTestId('expo-image-img-1')).toBeNull(); + // The placeholder is not tappable, so a broken image cannot be opened full screen. + expect(queryByTestId('image-img-1-touchable')).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Call image failed to load' })); - // Test at end - activeIndex = 4; - expect(handlePrevious()).toBe(3); - expect(handleNext()).toBe(4); // Should not exceed bounds + unmount(); }); }); - describe('Full Screen Image Modal', () => { - it('should open full screen modal when image is tapped', () => { - const { getByTestId, queryByTestId } = render(); + describe('empty, loading and error states', () => { + it('shows the zero state when the call has no images', () => { + setDetailState({ callImages: [] }); + const { getByText, queryByTestId, unmount } = render(); + + expect(getByText('callImages.no_images')).toBeTruthy(); + expect(getByText('callImages.no_images_description')).toBeTruthy(); + expect(queryByTestId('next-button')).toBeNull(); + + unmount(); + }); - // Initially, full screen modal should not be visible - expect(queryByTestId('full-screen-modal')).toBeFalsy(); + it('shows the zero state when every image is unusable', () => { + setDetailState({ callImages: [blankImage] }); + const { getByText, unmount } = render(); - // Tap on an image - const imageTouchable = getByTestId('image-1-touchable'); - fireEvent.press(imageTouchable); + expect(getByText('callImages.no_images')).toBeTruthy(); - // Full screen modal should now be visible - expect(getByTestId('full-screen-modal')).toBeTruthy(); + unmount(); }); - it('should close full screen modal when close button is pressed', () => { - const { getByTestId, queryByTestId } = render(); + it('shows a loading state instead of the gallery while images are being fetched', () => { + setDetailState({ callImages: null, isLoadingImages: true }); + const { getByText, queryByText, unmount } = render(); - // Open full screen modal - const imageTouchable = getByTestId('image-1-touchable'); - fireEvent.press(imageTouchable); + expect(getByText('callImages.loading')).toBeTruthy(); + expect(queryByText('callImages.add')).toBeNull(); - expect(getByTestId('full-screen-modal')).toBeTruthy(); + unmount(); + }); + + it('surfaces the store error message', () => { + setDetailState({ callImages: [], errorImages: 'Network unreachable' }); + const { getByText, unmount } = render(); - // Close full screen modal - const fullScreenCloseButton = getByTestId('full-screen-close-button'); - fireEvent.press(fullScreenCloseButton); + expect(getByText('callImages.error')).toBeTruthy(); + expect(getByText('Network unreachable')).toBeTruthy(); - // Full screen modal should be closed - expect(queryByTestId('full-screen-modal')).toBeFalsy(); + unmount(); }); + }); - it('should display correct image in full screen modal', () => { - const { getByTestId } = render(); + describe('full screen viewer', () => { + it('opens the viewer with the resolved source and name of the tapped image', () => { + const { getByTestId, unmount } = render(); - // Tap on first image - const imageTouchable = getByTestId('image-1-touchable'); - fireEvent.press(imageTouchable); + fireEvent.press(getByTestId('image-img-1-touchable')); - // Check that the correct image is displayed in full screen - const fullScreenImage = getByTestId('full-screen-image'); - expect(fullScreenImage.props.source.uri).toBe('data:image/png;base64,base64data1'); - expect(fullScreenImage.props.alt).toBe('Image 1'); + const viewer = getByTestId('full-screen-modal'); + expect(viewer.props.imageSource).toEqual({ uri: 'data:image/jpeg;base64,AAAABBBB' }); + expect(viewer.props.imageName).toBe('Front of structure'); + + unmount(); }); - it('should handle full screen modal for URL-based images', () => { - const { getByTestId } = render(); + it('keeps the viewer closed until an image is tapped', () => { + const { queryByTestId, unmount } = render(); - // Tap on second image (URL-based) - const imageTouchable = getByTestId('image-2-touchable'); - fireEvent.press(imageTouchable); + expect(queryByTestId('full-screen-modal')).toBeNull(); - // Check that the correct image is displayed in full screen - const fullScreenImage = getByTestId('full-screen-image'); - expect(fullScreenImage.props.source.uri).toBe('https://example.com/image2.jpg'); - expect(fullScreenImage.props.alt).toBe('Image 2'); + unmount(); }); - it('should not open full screen modal for images with errors', () => { - const invalidImagesStore = { - ...mockStore, - callImages: [ - { - Id: '1', - Name: 'Valid Image', - Data: 'base64data1', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-01', - }, - { - Id: '2', - Name: 'Invalid Image', - Data: '', - Url: '', - Mime: 'image/png', - Timestamp: '2023-01-02', - } - ] - }; - - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(invalidImagesStore as any) : invalidImagesStore as any); - - const { getByTestId, queryByTestId } = render(); - - // Should have valid image touchable - expect(getByTestId('image-1-touchable')).toBeTruthy(); - // Should not have invalid image in gallery (filtered out) - expect(queryByTestId('image-2-touchable')).toBeFalsy(); + it('closes the viewer again', () => { + const { getByTestId, queryByTestId, unmount } = render(); + + fireEvent.press(getByTestId('image-img-1-touchable')); + fireEvent.press(getByTestId('full-screen-close')); + + expect(queryByTestId('full-screen-modal')).toBeNull(); + + unmount(); }); }); - describe('Image Slider Improvements', () => { - it('should have proper width styling for image containers', () => { - const { getByTestId } = render(); + describe('choosing an image', () => { + it('offers the gallery and camera options behind the add button', () => { + const { getByText, unmount } = render(); - // Check that images are properly contained - expect(getByTestId('image-1')).toBeTruthy(); - expect(getByTestId('image-2')).toBeTruthy(); - expect(getByTestId('image-4')).toBeTruthy(); - expect(getByTestId('image-5')).toBeTruthy(); - }); + fireEvent.press(getByText('callImages.add')); - it('should center images properly', () => { - // This test verifies that images use contentFit="contain" for proper centering - // In the actual implementation, this is handled by the Image component props - const { getByTestId } = render(); + expect(getByText('callImages.add_new')).toBeTruthy(); + expect(getByText('callImages.select_from_gallery')).toBeTruthy(); + expect(getByText('callImages.take_photo')).toBeTruthy(); - const imageContainer = getByTestId('image-1'); - expect(imageContainer).toBeTruthy(); + unmount(); }); - it('should handle touch interactions correctly', () => { - const { getByTestId } = render(); + it('offers no note or upload until an image has been chosen', () => { + const { getByText, getByTestId, queryByTestId, unmount } = render(); + + fireEvent.press(getByText('callImages.add')); - // Should be able to interact with image touchables - const imageTouchable = getByTestId('image-1-touchable'); - expect(imageTouchable).toBeTruthy(); + expect(getByTestId('cancel-add-button')).toBeTruthy(); + expect(queryByTestId('image-note-input')).toBeNull(); + expect(queryByTestId('upload-button')).toBeNull(); - // Touching should not throw an error - expect(() => fireEvent.press(imageTouchable)).not.toThrow(); + unmount(); }); - }); - describe('Analytics', () => { - it('should track analytics event when modal is opened', () => { - render(); + it('asks for media library permission on iOS and shows the preview once granted', async () => { + const screen = render(); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-id', - hasExistingImages: true, - imagesCount: 4, - isLoadingImages: false, - hasError: false, - }); + await pickImageFromLibrary(screen); + + expect(mockedPicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled(); + expect(mockedPicker.launchImageLibraryAsync).toHaveBeenCalledWith(expect.objectContaining({ mediaTypes: ['images'], allowsEditing: true, quality: 0.8 })); + expect(screen.getByTestId('expo-image').props.source).toEqual({ uri: 'file:///tmp/original.jpg' }); + + screen.unmount(); }); - it('should not track analytics event when modal is closed', () => { - render(); + it('tells the user and opens no picker when library permission is denied', async () => { + mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'denied' } as never); + const { getByText, queryByTestId, unmount } = render(); - expect(mockTrackEvent).not.toHaveBeenCalled(); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.select_from_gallery')); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'common.permission_denied')); + // Feedback goes through the app's toast, never a bare alert(). + expect(mockAlert).not.toHaveBeenCalled(); + expect(mockedPicker.launchImageLibraryAsync).not.toHaveBeenCalled(); + expect(queryByTestId('image-note-input')).toBeNull(); + + unmount(); }); - it('should track analytics event with existing images', () => { - const mockImagesStore = { - ...mockStore, - callImages: [ - { Id: '1', Name: 'Image 1', Data: 'base64data', Url: '', Timestamp: '2024-01-01', Mime: 'image/png' }, - { Id: '2', Name: 'Image 2', Data: 'base64data2', Url: '', Timestamp: '2024-01-02', Mime: 'image/jpeg' }, - ], - }; + it('stays on the chooser when the user cancels the picker', async () => { + mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchImageLibraryAsync.mockResolvedValue({ canceled: true, assets: null } as never); + const { getByText, queryByTestId, unmount } = render(); - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockImagesStore as any) : mockImagesStore as any); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.select_from_gallery')); - render(); + await waitFor(() => expect(mockedPicker.launchImageLibraryAsync).toHaveBeenCalled()); + expect(queryByTestId('image-note-input')).toBeNull(); + expect(getByText('callImages.select_from_gallery')).toBeTruthy(); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-456', - hasExistingImages: true, - imagesCount: 2, - isLoadingImages: false, - hasError: false, - }); + unmount(); }); - it('should track analytics event with loading state', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - isLoadingImages: true, - } as any) : { - ...mockStore, - isLoadingImages: true, - } as any); + it('reports a failing picker to the user and to the log', async () => { + mockedPicker.requestMediaLibraryPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchImageLibraryAsync.mockRejectedValue(new Error('picker exploded')); + const { getByText, unmount } = render(); - render(); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.select_from_gallery')); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-789', - hasExistingImages: true, - imagesCount: 4, - isLoadingImages: true, - hasError: false, - }); + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.error_selecting_image')); + expect(mockAlert).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error selecting image from library' })); + + unmount(); }); - it('should track analytics event with error state', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - errorImages: 'Failed to load images', - } as any) : { - ...mockStore, - errorImages: 'Failed to load images', - } as any); + it('asks for camera permission and opens the back camera', async () => { + mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchCameraAsync.mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///tmp/shot.jpg' }] } as never); + const { getByText, getByTestId, unmount } = render(); - render(); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.take_photo')); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-error', - hasExistingImages: true, - imagesCount: 4, - isLoadingImages: false, - hasError: true, - }); + await waitFor(() => expect(getByTestId('image-note-input')).toBeTruthy()); + expect(mockedPicker.requestCameraPermissionsAsync).toHaveBeenCalled(); + expect(mockedPicker.launchCameraAsync).toHaveBeenCalledWith(expect.objectContaining({ mediaTypes: ['images'], quality: 0.8, cameraType: 'back' })); + + unmount(); }); - it('should track analytics event only once when isOpen changes from false to true', () => { - const { rerender } = render(); + it('tells the user and opens no camera when camera permission is denied', async () => { + mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'denied' } as never); + const { getByText, unmount } = render(); - // Should not track when initially closed - expect(mockTrackEvent).not.toHaveBeenCalled(); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.take_photo')); - // Should track when opened - rerender(); + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'common.permission_denied')); + expect(mockAlert).not.toHaveBeenCalled(); + expect(mockedPicker.launchCameraAsync).not.toHaveBeenCalled(); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-id', - hasExistingImages: true, - imagesCount: 4, - isLoadingImages: false, - hasError: false, - }); + unmount(); + }); - // Should not track again when staying open - rerender(); + it('reports a failing camera to the user and to the log', async () => { + mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchCameraAsync.mockRejectedValue(new Error('camera exploded')); + const { getByText, unmount } = render(); - expect(mockTrackEvent).toHaveBeenCalledTimes(1); + fireEvent.press(getByText('callImages.add')); + fireEvent.press(getByText('callImages.take_photo')); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.error_capturing_image')); + expect(mockAlert).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error capturing image from camera' })); + + unmount(); }); - it('should track analytics event with no images', () => { - mockUseCallDetailStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - ...mockStore, - callImages: [], - } as any) : { - ...mockStore, - callImages: [], - } as any); + it('abandons the picked image when the user cancels', async () => { + const screen = render(); - render(); + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('cancel-add-button')); - expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { - callId: 'test-call-no-images', - hasExistingImages: false, - imagesCount: 0, - isLoadingImages: false, - hasError: false, - }); + expect(screen.queryByTestId('image-note-input')).toBeNull(); + expect(screen.getByText('Front of structure')).toBeTruthy(); + + screen.unmount(); }); }); - describe('Image Upload and PNG Conversion', () => { - beforeEach(() => { - mockManipulateAsync.mockClear(); - mockReadAsStringAsync.mockClear(); + describe('uploading', () => { + it('resizes to 1024px wide and compresses to PNG before uploading', async () => { + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockManipulateAsync).toHaveBeenCalled()); + expect(mockManipulateAsync).toHaveBeenCalledWith('file:///tmp/original.jpg', [{ resize: { width: 1024 } }], { compress: 0.8, format: 'png' }); + + screen.unmount(); }); - it('should convert images to PNG format before upload', async () => { - const mockManipulatedUri = 'file://path/to/manipulated.png'; - const mockBase64Data = 'base64EncodedPNGData'; + it('uploads the base64 of the resized file, not of the original', async () => { + const screen = render(); - mockManipulateAsync.mockResolvedValue({ - uri: mockManipulatedUri, - width: 1024, - height: 768, - }); + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); - mockReadAsStringAsync.mockResolvedValue(mockBase64Data); - - // Test the PNG conversion logic - const selectedImageUri = 'file://path/to/original.jpg'; - - // Simulate the logic from handleUploadImage - const manipulatedImage = await mockManipulateAsync( - selectedImageUri, - [{ resize: { width: 1024 } }], - { - compress: 0.8, - format: 'png', // PNG format - } - ); - - expect(mockManipulateAsync).toHaveBeenCalledWith( - selectedImageUri, - [{ resize: { width: 1024 } }], - { - compress: 0.8, - format: 'png', - } - ); - - expect(manipulatedImage.uri).toBe(mockManipulatedUri); - - // Read the manipulated image as base64 - const base64Image = await mockReadAsStringAsync(manipulatedImage.uri, { - encoding: 'base64', - }); + await waitFor(() => expect(mockReadAsStringAsync).toHaveBeenCalled()); + expect(mockReadAsStringAsync).toHaveBeenCalledWith('file:///tmp/manipulated.png', { encoding: 'base64' }); + + screen.unmount(); + }); + + it('sends the note, the file name, the user and the current position', async () => { + const screen = render(); - expect(mockReadAsStringAsync).toHaveBeenCalledWith( - mockManipulatedUri, - { encoding: 'base64' } - ); + await pickImageFromLibrary(screen); + fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked'); + fireEvent.press(screen.getByTestId('upload-button')); - expect(base64Image).toBe(mockBase64Data); + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', 'Rear entry blocked', 'original.jpg', 40.1, -75.2, 'BASE64PAYLOAD')); + + screen.unmount(); }); - it('should handle image manipulation errors gracefully', async () => { - const error = new Error('Image manipulation failed'); - mockManipulateAsync.mockRejectedValue(error); - - const selectedImageUri = 'file://path/to/original.jpg'; - - try { - await mockManipulateAsync( - selectedImageUri, - [{ resize: { width: 1024 } }], - { - compress: 0.8, - format: 'png', - } - ); - } catch (caughtError) { - expect(caughtError).toBe(error); - } - - expect(mockManipulateAsync).toHaveBeenCalled(); + it('uploads with an empty note when the user typed none', async () => { + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', '', 'original.jpg', 40.1, -75.2, 'BASE64PAYLOAD')); + + screen.unmount(); }); - it('should resize images to max width of 1024px while maintaining aspect ratio', async () => { - const selectedImageUri = 'file://path/to/large-image.jpg'; + it('generates a name for a library image that has none', async () => { + const screen = render(); - mockManipulateAsync.mockResolvedValue({ - uri: 'file://path/to/resized.png', - width: 1024, - height: 768, - }); + await pickImageFromLibrary(screen, { uri: 'file:///tmp/no-name.jpg' }); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalled()); + expect(mockUploadCallImage.mock.calls[0][3]).toMatch(/^image_\d+\.png$/); - await mockManipulateAsync( - selectedImageUri, - [{ resize: { width: 1024 } }], - { - compress: 0.8, - format: 'png', - } - ); - - expect(mockManipulateAsync).toHaveBeenCalledWith( - selectedImageUri, - [{ resize: { width: 1024 } }], - expect.objectContaining({ - compress: 0.8, - format: 'png', - }) - ); + screen.unmount(); }); - it('should apply 0.8 compression to the converted PNG', async () => { - const selectedImageUri = 'file://path/to/original.jpg'; + it('names camera captures after the moment they were taken', async () => { + mockedPicker.requestCameraPermissionsAsync.mockResolvedValue({ status: 'granted' } as never); + mockedPicker.launchCameraAsync.mockResolvedValue({ canceled: false, assets: [{ uri: 'file:///tmp/shot.jpg' }] } as never); + const screen = render(); - mockManipulateAsync.mockResolvedValue({ - uri: 'file://path/to/compressed.png', - width: 800, - height: 600, - }); + fireEvent.press(screen.getByText('callImages.add')); + fireEvent.press(screen.getByText('callImages.take_photo')); + await waitFor(() => expect(screen.getByTestId('upload-button')).toBeTruthy()); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalled()); + expect(mockUploadCallImage.mock.calls[0][3]).toMatch(/^camera_\d+\.png$/); - await mockManipulateAsync( - selectedImageUri, - [{ resize: { width: 1024 } }], - { - compress: 0.8, - format: 'png', - } - ); - - expect(mockManipulateAsync).toHaveBeenCalledWith( - selectedImageUri, - expect.any(Array), - expect.objectContaining({ - compress: 0.8, - }) - ); + screen.unmount(); }); - }); - describe('Image Note and Filename Handling', () => { - it('should use note input for the note field and filename for the name field', () => { - // Test the logic that separates note and filename - const mockImageInfo = { - uri: 'file://path/to/image.jpg', - filename: 'my_photo.jpg' - }; - const noteText = 'This is a test note'; - - // Simulate upload call parameters - const uploadParams = { - note: noteText, - name: mockImageInfo.filename, - }; - - expect(uploadParams.note).toBe('This is a test note'); - expect(uploadParams.name).toBe('my_photo.jpg'); + it('uploads without coordinates when the device has no fix', async () => { + mockLocationState.latitude = null; + mockLocationState.longitude = null; + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledWith('call-1', 'user-42', '', 'original.jpg', null, null, 'BASE64PAYLOAD')); + + screen.unmount(); }); - it('should generate filename for camera images', () => { - const timestamp = Date.now(); - const generatedFilename = `camera_${timestamp}.png`; + it('returns to the gallery with a clean form after a successful upload', async () => { + const screen = render(); - expect(generatedFilename).toMatch(/^camera_\d+\.png$/); + await pickImageFromLibrary(screen); + fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked'); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(screen.queryByTestId('image-note-input')).toBeNull()); + expect(screen.getByText('Front of structure')).toBeTruthy(); + + // The next add starts from the chooser, not from the previous selection. + fireEvent.press(screen.getByText('callImages.add')); + expect(screen.getByText('callImages.select_from_gallery')).toBeTruthy(); + + screen.unmount(); }); - it('should use original filename from gallery images or generate one', () => { - // Test with filename from asset - const assetWithFilename = { - fileName: 'vacation_photo.jpg', - uri: 'file://path/to/image.jpg' - }; - - const filename1 = assetWithFilename.fileName || `image_${Date.now()}.png`; - expect(filename1).toBe('vacation_photo.jpg'); - - // Test without filename (generate one) - const assetWithoutFilename = { - fileName: null, - uri: 'file://path/to/image.jpg' - }; - - const timestamp = Date.now(); - const filename2 = assetWithoutFilename.fileName || `image_${timestamp}.png`; - expect(filename2).toMatch(/^image_\d+\.png$/); + it('tells the user when the upload fails, and keeps the form and the typed note', async () => { + mockUploadCallImage.mockRejectedValue(new Error('upload rejected')); + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.changeText(screen.getByTestId('image-note-input'), 'Rear entry blocked'); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.upload_error')); + expect(mockAlert).not.toHaveBeenCalled(); + // The form is still there once the failed attempt settles, note and all. + expect(screen.getByText('callImages.upload')).toBeTruthy(); + expect(screen.getByTestId('image-note-input').props.value).toBe('Rear entry blocked'); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error uploading call image', context: expect.objectContaining({ callId: 'call-1' }) })); + + screen.unmount(); }); - }); - describe('Geolocation Integration', () => { - it('should include current location when uploading images', () => { - const mockLocationStore = { - latitude: 40.7128, - longitude: -74.0060, - heading: null, - accuracy: 10, - speed: null, - altitude: null, - timestamp: Date.now(), - isBackgroundEnabled: false, - isMapLocked: false, - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - setMapLocked: jest.fn(), - }; - - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStore) : mockLocationStore); - - // Simulate the upload logic - const uploadParams = { - latitude: mockLocationStore.latitude, - longitude: mockLocationStore.longitude, - }; - - expect(uploadParams.latitude).toBe(40.7128); - expect(uploadParams.longitude).toBe(-74.0060); + it('refuses to upload with no signed in user, and says why', async () => { + mockUserId = null; + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.not_signed_in')); + expect(mockUploadCallImage).not.toHaveBeenCalled(); + expect(mockManipulateAsync).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Cannot upload call image without a signed in user' })); + // The picked image is kept so the user can retry after signing in. + expect(screen.getByTestId('image-note-input')).toBeTruthy(); + + screen.unmount(); }); - it('should handle null location gracefully', () => { - const mockLocationStoreNoLocation = { - latitude: null, - longitude: null, - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - isBackgroundEnabled: false, - isMapLocked: false, - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - setMapLocked: jest.fn(), - }; - - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStoreNoLocation) : mockLocationStoreNoLocation); - - // Simulate the upload logic - const uploadParams = { - latitude: mockLocationStoreNoLocation.latitude, - longitude: mockLocationStoreNoLocation.longitude, - }; - - expect(uploadParams.latitude).toBeNull(); - expect(uploadParams.longitude).toBeNull(); + it('re-enables the upload button after a failure so the user can retry', async () => { + mockUploadCallImage.mockRejectedValueOnce(new Error('upload rejected')); + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(logger.error).toHaveBeenCalled()); + expect(screen.getByText('callImages.upload')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(mockUploadCallImage).toHaveBeenCalledTimes(2)); + + screen.unmount(); + }); + + it('never uploads when the resize step fails', async () => { + mockManipulateAsync.mockRejectedValue(new Error('manipulation failed')); + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Error uploading call image' }))); + expect(mockUploadCallImage).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith('error', 'callImages.upload_error'); + + screen.unmount(); }); - it('should only include location when both latitude and longitude are available', () => { - const mockLocationStorePartial = { - latitude: 40.7128, - longitude: null, // Missing longitude - heading: null, - accuracy: null, - speed: null, - altitude: null, - timestamp: null, - isBackgroundEnabled: false, - isMapLocked: false, - setLocation: jest.fn(), - setBackgroundEnabled: jest.fn(), - setMapLocked: jest.fn(), - }; - - mockUseLocationStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector(mockLocationStorePartial) : mockLocationStorePartial); - - // In the actual implementation, this would be handled by the API - // which checks if both latitude and longitude are provided - const hasCompleteLocation = mockLocationStorePartial.latitude !== null && - mockLocationStorePartial.longitude !== null; - - expect(hasCompleteLocation).toBe(false); + it('shows the uploading label while the upload is in flight', async () => { + let resolveUpload: () => void = () => undefined; + mockUploadCallImage.mockImplementation(() => new Promise((resolve) => (resolveUpload = resolve))); + const screen = render(); + + await pickImageFromLibrary(screen); + fireEvent.press(screen.getByTestId('upload-button')); + + await waitFor(() => expect(screen.getByText('common.uploading')).toBeTruthy()); + + resolveUpload(); + await waitFor(() => expect(screen.queryByTestId('image-note-input')).toBeNull()); + + screen.unmount(); }); }); - describe('UI Updates for Note Input', () => { - it('should have testID for image note input', () => { - const noteInputTestId = 'image-note-input'; - expect(noteInputTestId).toBe('image-note-input'); + describe('analytics', () => { + it('reports the modal opening with what it is showing', () => { + const { unmount } = render(); + + expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', { + callId: 'call-1', + hasExistingImages: true, + imagesCount: 2, + isLoadingImages: false, + hasError: false, + }); + + unmount(); }); - it('should use image_note translation key for placeholder', () => { - const placeholderKey = 'callImages.image_note'; - expect(placeholderKey).toBe('callImages.image_note'); + it('reports nothing while the modal is closed', () => { + const { unmount } = render(); + + expect(mockTrackEvent).not.toHaveBeenCalled(); + + unmount(); }); - it('should maintain the same upload button testID for consistency', () => { - const uploadButtonTestId = 'upload-button'; - expect(uploadButtonTestId).toBe('upload-button'); + it('reports an empty gallery as such', () => { + setDetailState({ callImages: [] }); + const { unmount } = render(); + + expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', expect.objectContaining({ hasExistingImages: false, imagesCount: 0 })); + + unmount(); }); - }); - describe('UI Layout and Accessibility', () => { - it('should have full-width input and save button in add image mode', () => { - // This test verifies the layout structure through the class names - // In a real test environment, you would use actual rendering - const inputClassName = 'w-full'; - const buttonClassName = 'w-full'; + it('reports the error state', () => { + setDetailState({ callImages: [], errorImages: 'Network unreachable' }); + const { unmount } = render(); - expect(inputClassName).toBe('w-full'); - expect(buttonClassName).toBe('w-full'); + expect(mockTrackEvent).toHaveBeenCalledWith('call_images_modal_opened', expect.objectContaining({ hasError: true })); + + unmount(); }); - it('should have testIDs for input and button elements', () => { - const inputTestId = 'image-note-input'; - const buttonTestId = 'upload-button'; + it('reports once per opening, not once per render', () => { + const { rerender, unmount } = render(); + + rerender(); + rerender(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); - expect(inputTestId).toBe('image-note-input'); - expect(buttonTestId).toBe('upload-button'); + unmount(); }); - it('should have fixed bottom section for input and save button', () => { - // This test verifies the layout structure - // The fixed bottom section should have border and background styling - const bottomSectionClasses = 'max-h-20 space-y-2 border-t border-gray-200 bg-white px-4 py-2 dark:border-gray-700 dark:bg-gray-800'; - - expect(bottomSectionClasses).toContain('max-h-20'); - expect(bottomSectionClasses).toContain('border-t'); - expect(bottomSectionClasses).toContain('bg-white'); - expect(bottomSectionClasses).toContain('dark:bg-gray-800'); - expect(bottomSectionClasses).toContain('px-4'); - expect(bottomSectionClasses).toContain('py-2'); + it('does not report again as the images finish loading', () => { + setDetailState({ callImages: null, isLoadingImages: true }); + const { rerender, unmount } = render(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + + // Images land: the store data changes under a re-rendering parent. A new onClose + // identity stands in for that parent render, since the component is memoized. + setDetailState({ callImages: [base64Image, urlImage], isLoadingImages: false }); + rerender(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + + unmount(); }); - it('should only show fixed bottom section when image is selected', () => { - // Logic test: bottom section should only render when selectedImage is truthy - const selectedImage = 'file://path/to/image.jpg'; - const shouldShowBottomSection = Boolean(selectedImage); + it('reports again once the modal is closed and reopened', () => { + const { rerender, unmount } = render(); - expect(shouldShowBottomSection).toBe(true); + rerender(); + rerender(); - const noSelectedImage = null; - const shouldNotShowBottomSection = Boolean(noSelectedImage); + expect(mockTrackEvent).toHaveBeenCalledTimes(2); - expect(shouldNotShowBottomSection).toBe(false); + unmount(); }); - it('should use flexbox layout for proper spacing', () => { - // Verify that the main container uses flex-1 for proper layout - const mainContainerClass = 'flex-1'; - const scrollViewStyle = { flex: 1 }; - const contentContainerStyle = { flexGrow: 1 }; + it('reports again when it is pointed at a different call', () => { + const { rerender, unmount } = render(); + + rerender(); + + expect(mockTrackEvent).toHaveBeenCalledTimes(2); + expect(mockTrackEvent).toHaveBeenLastCalledWith('call_images_modal_opened', expect.objectContaining({ callId: 'call-9' })); - expect(mainContainerClass).toBe('flex-1'); - expect(scrollViewStyle.flex).toBe(1); - expect(contentContainerStyle.flexGrow).toBe(1); + unmount(); }); }); }); diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx index 88125d7a..93bd27f8 100644 --- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx @@ -6,71 +6,74 @@ import { DispatchSelectionModal } from '../dispatch-selection-modal'; // Mock dependencies jest.mock('@/stores/dispatch/store', () => ({ - useDispatchStore: (selector: any) => typeof selector === 'function' ? selector({ - data: { - users: [], - groups: [], - roles: [], - units: [], - }, - selection: { - everyone: false, - users: [], - groups: [], - roles: [], - units: [], - }, - isLoading: false, - error: null, - searchQuery: '', - fetchDispatchData: jest.fn(), - setSelection: jest.fn(), - toggleEveryone: jest.fn(), - toggleUser: jest.fn(), - toggleGroup: jest.fn(), - toggleRole: jest.fn(), - toggleUnit: jest.fn(), - setSearchQuery: jest.fn(), - clearSelection: jest.fn(), - getFilteredData: () => ({ - users: [], - groups: [], - roles: [], - units: [], - }), - }) : { - data: { - users: [], - groups: [], - roles: [], - units: [], - }, - selection: { - everyone: false, - users: [], - groups: [], - roles: [], - units: [], - }, - isLoading: false, - error: null, - searchQuery: '', - fetchDispatchData: jest.fn(), - setSelection: jest.fn(), - toggleEveryone: jest.fn(), - toggleUser: jest.fn(), - toggleGroup: jest.fn(), - toggleRole: jest.fn(), - toggleUnit: jest.fn(), - setSearchQuery: jest.fn(), - clearSelection: jest.fn(), - getFilteredData: () => ({ - users: [], - groups: [], - roles: [], - units: [], - }), - }, + useDispatchStore: (selector: any) => + typeof selector === 'function' + ? selector({ + data: { + users: [], + groups: [], + roles: [], + units: [], + }, + selection: { + everyone: false, + users: [], + groups: [], + roles: [], + units: [], + }, + isLoading: false, + error: null, + searchQuery: '', + fetchDispatchData: jest.fn(), + setSelection: jest.fn(), + toggleEveryone: jest.fn(), + toggleUser: jest.fn(), + toggleGroup: jest.fn(), + toggleRole: jest.fn(), + toggleUnit: jest.fn(), + setSearchQuery: jest.fn(), + clearSelection: jest.fn(), + getFilteredData: () => ({ + users: [], + groups: [], + roles: [], + units: [], + }), + }) + : { + data: { + users: [], + groups: [], + roles: [], + units: [], + }, + selection: { + everyone: false, + users: [], + groups: [], + roles: [], + units: [], + }, + isLoading: false, + error: null, + searchQuery: '', + fetchDispatchData: jest.fn(), + setSelection: jest.fn(), + toggleEveryone: jest.fn(), + toggleUser: jest.fn(), + toggleGroup: jest.fn(), + toggleRole: jest.fn(), + toggleUnit: jest.fn(), + setSearchQuery: jest.fn(), + clearSelection: jest.fn(), + getFilteredData: () => ({ + users: [], + groups: [], + roles: [], + units: [], + }), + }, })); jest.mock('nativewind', () => ({ @@ -96,6 +99,15 @@ jest.mock('@/components/ui/actionsheet', () => { ), ActionsheetDragIndicator: () => , ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + // The recipient list is virtualized (FlashList); render every row so the + // sheet's content stays reachable in the test tree. + ActionsheetFlatList: ({ data, renderItem, keyExtractor, testID }: any) => ( + + {(data ?? []).map((item: any, index: number) => ( + {renderItem({ item, index })} + ))} + + ), }; }); @@ -120,9 +132,7 @@ describe('DispatchSelectionModal', () => { }); it('should not render when not visible', () => { - const { queryByText } = render( - - ); + const { queryByText } = render(); expect(queryByText('calls.select_dispatch_recipients')).toBeNull(); }); @@ -139,4 +149,4 @@ describe('DispatchSelectionModal', () => { expect(screen.getByText('common.confirm')).toBeTruthy(); expect(screen.getByText('common.cancel')).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx index 19cb8286..88ce741c 100644 --- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx +++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx @@ -33,12 +33,8 @@ const mockDispatchStore = { Roles: [], }, ], - groups: [ - { GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }, - ], - roles: [ - { UnitRoleId: '1', Name: 'Captain', UnitId: '1' }, - ], + groups: [{ GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }], + roles: [{ UnitRoleId: '1', Name: 'Captain', UnitId: '1' }], units: [ { UnitId: '1', @@ -108,12 +104,8 @@ const mockDispatchStore = { Roles: [], }, ], - groups: [ - { GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }, - ], - roles: [ - { UnitRoleId: '1', Name: 'Captain', UnitId: '1' }, - ], + groups: [{ GroupId: '1', Name: 'Fire Department', TypeId: 1, Address: '', GroupType: 'Fire' }], + roles: [{ UnitRoleId: '1', Name: 'Captain', UnitId: '1' }], units: [ { UnitId: '1', @@ -140,7 +132,7 @@ const mockDispatchStore = { }; jest.mock('@/stores/dispatch/store', () => ({ - useDispatchStore: jest.fn((selector: any) => typeof selector === 'function' ? selector(mockDispatchStore) : mockDispatchStore), + useDispatchStore: jest.fn((selector: any) => (typeof selector === 'function' ? selector(mockDispatchStore) : mockDispatchStore)), })); // Mock the color scheme and cssInterop @@ -164,6 +156,15 @@ jest.mock('@/components/ui/actionsheet', () => { ), ActionsheetDragIndicator: () => , ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + // The recipient list is virtualized (FlashList); render every row so the + // sheet's content stays reachable in the test tree. + ActionsheetFlatList: ({ data, renderItem, keyExtractor, testID }: any) => ( + + {(data ?? []).map((item: any, index: number) => ( + {renderItem({ item, index })} + ))} + + ), }; }); @@ -206,9 +207,7 @@ describe('DispatchSelectionModal', () => { }); it('should not render when not visible', () => { - const { queryByText } = render( - - ); + const { queryByText } = render(); expect(queryByText('calls.select_dispatch_recipients')).toBeNull(); }); diff --git a/src/components/calls/__tests__/full-screen-image-modal.test.tsx b/src/components/calls/__tests__/full-screen-image-modal.test.tsx index 931b662a..eec98887 100644 --- a/src/components/calls/__tests__/full-screen-image-modal.test.tsx +++ b/src/components/calls/__tests__/full-screen-image-modal.test.tsx @@ -1,325 +1,479 @@ +/** + * Exercises the real FullScreenImageModal. + * + * Only dependencies are mocked, and the two that carry behaviour (reanimated shared + * values and the gesture builders) are mocked as thin, faithful plumbing: shared values + * are real mutable objects and the gesture callbacks registered by the component are + * captured so the component's own clamping/reset logic runs for real. + */ +import { act, fireEvent, render } from '@testing-library/react-native'; import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; -import { useTranslation } from 'react-i18next'; +import { Dimensions, StyleSheet } from 'react-native'; -// Mock dependencies jest.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), + useTranslation: () => ({ t: (key: string) => key }), })); +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref })); + return new Proxy({}, { get: () => icon }); +}); + +// gluestack's Modal only mounts its children while isOpen; the backdrop is pressable. +jest.mock('@/components/ui/modal', () => { + const React = require('react'); + const { Pressable, View } = require('react-native'); + return { + Modal: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'modal' }, children) : null), + ModalBackdrop: ({ onPress, ...props }: any) => React.createElement(Pressable, { testID: 'modal-backdrop', onPress, ...props }), + ModalContent: ({ children, ...props }: any) => React.createElement(View, { testID: 'modal-content', ...props }, children), + }; +}); + +jest.mock('@/components/ui/image', () => { + const React = require('react'); + const { Image: RNImage } = require('react-native'); + return { Image: (props: Record) => React.createElement(RNImage, props) }; +}); + jest.mock('react-native-reanimated', () => { - const View = require('react-native/Libraries/Components/View/View'); + const React = require('react'); + const { View } = require('react-native'); + + const clampedLerp = (value: number, input: number[], output: number[]) => { + const [inMin, inMax] = input; + const [outMin, outMax] = output; + if (value <= inMin) return outMin; + if (value >= inMax) return outMax; + return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin); + }; + + // Every function handed to runOnJS, so tests can check it is a stable JS-thread + // reference rather than a closure minted inside the worklet. + const runOnJSTargets: unknown[] = []; + return { + __esModule: true, + __runOnJSTargets: runOnJSTargets, default: { - View, + View: ({ children, ...props }: any) => React.createElement(View, props, children), + }, + // Real mutable boxes so the component's reads/writes behave like shared values. + useSharedValue: (initial: number) => { + const ref = React.useRef({ value: initial }); + return ref.current; + }, + useAnimatedStyle: (factory: () => Record) => factory(), + withTiming: (value: number) => value, + interpolate: clampedLerp, + runOnJS: (fn: (...args: unknown[]) => unknown) => { + runOnJSTargets.push(fn); + return fn; }, - useSharedValue: jest.fn(() => ({ value: 0 })), - useAnimatedStyle: jest.fn(() => ({})), - withTiming: jest.fn((value) => value), - interpolate: jest.fn(), - runOnJS: jest.fn((fn) => fn), }; }); -jest.mock('react-native-gesture-handler', () => ({ - Gesture: { - Pinch: jest.fn(() => ({ - onUpdate: jest.fn().mockReturnThis(), - onEnd: jest.fn().mockReturnThis(), - })), - Pan: jest.fn(() => ({ - onUpdate: jest.fn().mockReturnThis(), - onEnd: jest.fn().mockReturnThis(), - })), - Tap: jest.fn(() => ({ - numberOfTaps: jest.fn().mockReturnThis(), - onEnd: jest.fn().mockReturnThis(), - })), - Simultaneous: jest.fn(), - }, - GestureDetector: ({ children }: any) => children, -})); +// Captures the gesture callbacks the component registers so tests can drive them. +jest.mock('react-native-gesture-handler', () => { + const captured: Record void; end?: () => void }> = { + pinch: {}, + pan: {}, + tap: {}, + }; -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 44, bottom: 34, left: 0, right: 0 }), -})); + const build = (kind: string) => { + const gesture: any = { + onUpdate: (fn: (e: any) => void) => { + captured[kind].update = fn; + return gesture; + }, + onEnd: (fn: () => void) => { + captured[kind].end = fn; + return gesture; + }, + numberOfTaps: () => gesture, + }; + return gesture; + }; -// Create MockFullScreenImageModal to avoid CSS interop issues -interface FullScreenImageModalProps { - isOpen: boolean; - onClose: () => void; - imageSource: { uri: string }; - imageName?: string; -} - -const MockFullScreenImageModal: React.FC = ({ - isOpen, - onClose, - imageSource, - imageName, -}) => { - const { t } = useTranslation(); - - if (!isOpen) return null; - - // Mock React Native components for testing - const View = (props: any) => React.createElement('div', { testID: props.testID, ...props }); - const TouchableOpacity = (props: any) => React.createElement('button', { - testID: props.testID, - onPress: props.onPress, - onClick: props.onPress, - ...props - }); - const Image = (props: any) => React.createElement('img', { - testID: props.testID, - src: props.source?.uri, - alt: props.alt, - ...props - }); + return { + __captured: captured, + Gesture: { + Pinch: () => build('pinch'), + Pan: () => build('pan'), + Tap: () => build('tap'), + Simultaneous: (...gestures: unknown[]) => gestures[0], + }, + GestureDetector: ({ children }: any) => children, + }; +}); + +import FullScreenImageModal from '../full-screen-image-modal'; - return React.createElement(View, { testID: 'full-screen-modal' }, [ - React.createElement(View, { testID: 'modal-backdrop', key: 'backdrop' }), - React.createElement(View, { testID: 'modal-content', key: 'content' }, [ - React.createElement(View, { testID: 'close-button-container', key: 'close-container' }, - React.createElement(TouchableOpacity, { - testID: 'close-button', - key: 'close-button', - onPress: onClose - }, 'Close') - ), - React.createElement(View, { testID: 'image-container', key: 'image-container' }, - React.createElement(Image, { - testID: 'full-screen-image', - key: 'image', - source: imageSource, - alt: imageName || t('callImages.image_alt') - }) - ) - ]) - ]); +const { __captured: gestures } = require('react-native-gesture-handler') as { + __captured: Record void; end?: () => void }>; }; -// Mock the actual component -jest.mock('../full-screen-image-modal', () => ({ - __esModule: true, - default: MockFullScreenImageModal, -})); +const { __runOnJSTargets: runOnJSTargets } = require('react-native-reanimated') as { __runOnJSTargets: unknown[] }; -describe('FullScreenImageModal', () => { - const defaultProps = { - isOpen: true, - onClose: jest.fn(), - imageSource: { uri: 'https://example.com/image.jpg' }, - imageName: 'Test Image', +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + imageSource: { uri: 'https://example.com/image.jpg' }, + imageName: 'Test Image', +}; + +type RenderedNode = { type: string; props: Record; children: (RenderedNode | string)[] | null }; + +/** The rendered wrapper around the element carrying `testID` — i.e. the animated container. */ +const wrapperOf = (tree: any, testID: string): RenderedNode => { + const roots: RenderedNode[] = Array.isArray(tree) ? tree : [tree]; + let found: RenderedNode | undefined; + + const walk = (node: RenderedNode, parent?: RenderedNode) => { + if (found || typeof node !== 'object' || node === null) return; + if (node.props?.testID === testID && parent) { + found = parent; + return; + } + (node.children ?? []).forEach((child) => (typeof child === 'object' ? walk(child, node) : undefined)); }; + roots.forEach((root) => walk(root)); + if (!found) throw new Error(`No rendered wrapper found around testID "${testID}"`); + return found; +}; + +/** The transform handed to the animated image container: [{scale},{translateX},{translateY}]. */ +const readTransform = (tree: any) => { + const style = StyleSheet.flatten(wrapperOf(tree, 'full-screen-image').props.style) as { transform: Record[] }; + return Object.assign({}, ...style.transform) as { scale: number; translateX: number; translateY: number }; +}; + +const readCloseButtonOpacity = (tree: any) => (StyleSheet.flatten(wrapperOf(tree, 'close-button').props.style) as { opacity: number }).opacity; + +describe('FullScreenImageModal', () => { beforeEach(() => { jest.clearAllMocks(); + runOnJSTargets.length = 0; }); - describe('Basic functionality', () => { - it('renders correctly when open', () => { - const { getByTestId } = render(); - expect(getByTestId('full-screen-modal')).toBeTruthy(); + describe('image source resolution', () => { + it('hands the remote URL it was given straight to the image', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('full-screen-image').props.source).toEqual({ uri: 'https://example.com/image.jpg' }); + + unmount(); }); - it('does not render when closed', () => { - const { queryByTestId } = render(); - expect(queryByTestId('full-screen-modal')).toBeFalsy(); + it('passes a base64 data payload through untouched', () => { + const dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA'; + const { getByTestId, unmount } = render(); + + expect(getByTestId('full-screen-image').props.source).toEqual({ uri: dataUri }); + + unmount(); }); - it('displays the image correctly', () => { - const { getByTestId } = render(); + it('renders the image at the full screen width and contains it', () => { + const { getByTestId, unmount } = render(); + const image = getByTestId('full-screen-image'); - expect(image).toBeTruthy(); - expect(image.props.src).toBe('https://example.com/image.jpg'); - expect(image.props.alt).toBe('Test Image'); + expect(StyleSheet.flatten(image.props.style)).toMatchObject({ width: screenWidth, maxWidth: screenWidth }); + expect(image.props.contentFit).toBe('contain'); + + unmount(); }); - it('calls onClose when close button is pressed', () => { - const mockOnClose = jest.fn(); - const { getByTestId } = render(); + it('labels the image with the supplied name', () => { + const { getByTestId, unmount } = render(); - const closeButton = getByTestId('close-button'); - fireEvent.press(closeButton); + expect(getByTestId('full-screen-image').props.alt).toBe('Test Image'); - expect(mockOnClose).toHaveBeenCalledTimes(1); + unmount(); }); - it('uses fallback alt text when no image name provided', () => { - const propsWithoutName = { ...defaultProps, imageName: undefined }; - const { getByTestId } = render(); + it('falls back to the translated alt text when no name is supplied', () => { + const { getByTestId, unmount } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.alt).toBe('callImages.image_alt'); + expect(getByTestId('full-screen-image').props.alt).toBe('callImages.image_alt'); + + unmount(); + }); + + it('falls back to the translated alt text for an empty name', () => { + const { getByTestId, unmount } = render(); + + expect(getByTestId('full-screen-image').props.alt).toBe('callImages.image_alt'); + + unmount(); }); }); - describe('Image source handling', () => { - it('handles base64 image sources', () => { - const base64Props = { - ...defaultProps, - imageSource: { uri: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...' }, - }; + describe('visibility and closing', () => { + it('mounts nothing while closed', () => { + const { queryByTestId, unmount } = render(); - const { getByTestId } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.src).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...'); + expect(queryByTestId('modal')).toBeNull(); + expect(queryByTestId('full-screen-image')).toBeNull(); + + unmount(); }); - it('handles URL image sources', () => { - const urlProps = { - ...defaultProps, - imageSource: { uri: 'https://example.com/test-image.jpg' }, - }; + it('closes when the close button is pressed', () => { + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); - const { getByTestId } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.src).toBe('https://example.com/test-image.jpg'); + fireEvent.press(getByTestId('close-button')); + + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); }); - it('handles empty image source gracefully', () => { - const emptyProps = { - ...defaultProps, - imageSource: { uri: '' }, - }; + it('closes when the backdrop is pressed', () => { + const onClose = jest.fn(); + const { getByTestId, unmount } = render(); - const { getByTestId } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.src).toBe(''); + fireEvent.press(getByTestId('modal-backdrop')); + + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('does not close on its own while simply open', () => { + const onClose = jest.fn(); + const { unmount } = render(); + + expect(onClose).not.toHaveBeenCalled(); + + unmount(); }); }); - describe('Gesture handling capabilities', () => { - it('should have gesture detector in component structure', () => { - const { GestureDetector } = require('react-native-gesture-handler'); + describe('pinch to zoom', () => { + it('starts unzoomed and unpanned', () => { + const { toJSON, unmount } = render(); - render(); + expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 }); - // Verify that GestureDetector is available for use - expect(typeof GestureDetector).toBe('function'); + unmount(); }); - it('should have access to gesture creation functions', () => { - const { Gesture } = require('react-native-gesture-handler'); + it('applies the pinch factor to the current scale', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 2 }); + rerender(); - // Verify that Gesture creation functions are available - expect(typeof Gesture.Pinch).toBe('function'); - expect(typeof Gesture.Pan).toBe('function'); - expect(typeof Gesture.Tap).toBe('function'); - expect(typeof Gesture.Simultaneous).toBe('function'); + expect(readTransform(toJSON()).scale).toBe(2); + + unmount(); }); - }); - describe('Animation capabilities', () => { - it('should have access to reanimated hooks', () => { - const { useSharedValue, useAnimatedStyle } = require('react-native-reanimated'); + it('never zooms past 5x however hard the user pinches', () => { + const { rerender, toJSON, unmount } = render(); - // Verify that animation hooks are available for use - expect(typeof useSharedValue).toBe('function'); - expect(typeof useAnimatedStyle).toBe('function'); + gestures.pinch.update!({ scale: 50 }); + rerender(); + + expect(readTransform(toJSON()).scale).toBe(5); + + unmount(); }); - it('should have access to animation utilities', () => { - const { withTiming, interpolate } = require('react-native-reanimated'); + it('never shrinks the image below its natural size', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 0.1 }); + rerender(); - // Verify that animation utilities are available - expect(typeof withTiming).toBe('function'); - expect(typeof interpolate).toBe('function'); + expect(readTransform(toJSON()).scale).toBe(1); + + unmount(); + }); + + it('accumulates across successive pinches instead of restarting from 1', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 2 }); + gestures.pinch.end!(); + gestures.pinch.update!({ scale: 2 }); + rerender(); + + expect(readTransform(toJSON()).scale).toBe(4); + + unmount(); }); }); - describe('Modal state management', () => { - it('should reset animation values when modal opens', () => { - const { rerender } = render(); + describe('pan', () => { + it('refuses to move an image that is not zoomed in', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pan.update!({ translationX: 400, translationY: 400 }); + rerender(); - // Open the modal - rerender(); + expect(readTransform(toJSON())).toMatchObject({ translateX: 0, translateY: 0 }); - // Verify the modal is rendered - const { getByTestId } = render(); - expect(getByTestId('full-screen-modal')).toBeTruthy(); + unmount(); }); - it('should handle modal close correctly', () => { - const mockOnClose = jest.fn(); - const { getByTestId } = render(); + it('moves a zoomed image by the drag distance', () => { + const { rerender, toJSON, unmount } = render(); - const closeButton = getByTestId('close-button'); - fireEvent.press(closeButton); + gestures.pinch.update!({ scale: 2 }); + gestures.pinch.end!(); + gestures.pan.update!({ translationX: 40, translationY: 60 }); + rerender(); - expect(mockOnClose).toHaveBeenCalledTimes(1); + expect(readTransform(toJSON())).toMatchObject({ translateX: 40, translateY: 60 }); + + unmount(); }); - }); - describe('Accessibility', () => { - it('should have testID for close button', () => { - const { getByTestId } = render(); - expect(getByTestId('close-button')).toBeTruthy(); + it('keeps the image edges from being dragged inside the viewport', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 2 }); + gestures.pinch.end!(); + gestures.pan.update!({ translationX: 999999, translationY: 999999 }); + rerender(); + + // At 2x the image overflows by one screen, so it may travel half a screen each way. + expect(readTransform(toJSON())).toMatchObject({ translateX: screenWidth / 2, translateY: screenHeight / 2 }); + + unmount(); }); - it('should have testID for full screen image', () => { - const { getByTestId } = render(); - expect(getByTestId('full-screen-image')).toBeTruthy(); + it('clamps against the window it is currently displayed in, not the one it started in', () => { + const original = Dimensions.get('window'); + const { rerender, toJSON, unmount } = render(); + + // Rotate: the window swaps its width and height. + act(() => { + Dimensions.set({ window: { ...original, width: original.height, height: original.width } }); + }); + + gestures.pinch.update!({ scale: 2 }); + gestures.pinch.end!(); + gestures.pan.update!({ translationX: 999999, translationY: 999999 }); + rerender(); + + expect(readTransform(toJSON())).toMatchObject({ translateX: original.height / 2, translateY: original.width / 2 }); + + act(() => { + Dimensions.set({ window: original }); + }); + unmount(); }); - it('should provide alt text for the image', () => { - const { getByTestId } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.alt).toBe('Test Image'); + it('pulls an off-centre image back into bounds when the user zooms back out', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 3 }); + gestures.pinch.end!(); + gestures.pan.update!({ translationX: 999999, translationY: 999999 }); + gestures.pan.end!(); + gestures.pinch.update!({ scale: 1 / 3 }); + gestures.pinch.end!(); + rerender(); + + expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 }); + + unmount(); }); }); - describe('Edge cases', () => { - it('should handle undefined image name gracefully', () => { - const propsWithUndefinedName = { ...defaultProps, imageName: undefined }; - const { getByTestId } = render(); + describe('double tap', () => { + it('zooms to 2x', () => { + const { rerender, toJSON, unmount } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.alt).toBe('callImages.image_alt'); + gestures.tap.end!(); + rerender(); + + expect(readTransform(toJSON()).scale).toBe(2); + + unmount(); }); - it('should handle empty string image name', () => { - const propsWithEmptyName = { ...defaultProps, imageName: '' }; - const { getByTestId } = render(); + it('returns to fit, and to centre, on the next double tap', () => { + const { rerender, toJSON, unmount } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.alt).toBe('callImages.image_alt'); + gestures.tap.end!(); + gestures.pan.update!({ translationX: 999999, translationY: 999999 }); + gestures.pan.end!(); + gestures.tap.end!(); + rerender(); + + expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 }); + + unmount(); }); - it('should handle invalid image URI gracefully', () => { - const propsWithInvalidUri = { - ...defaultProps, - imageSource: { uri: 'invalid-uri' }, - }; + it('hands runOnJS one stable function instead of a fresh worklet-local closure each tap', () => { + const { rerender, unmount } = render(); - const { getByTestId } = render(); - const image = getByTestId('full-screen-image'); - expect(image.props.src).toBe('invalid-uri'); + gestures.tap.end!(); + rerender(); + gestures.tap.end!(); + + // Reanimated cannot transfer a closure created inside the worklet to the JS + // thread; the same JS-thread reference must be scheduled every time. + expect(runOnJSTargets.length).toBeGreaterThanOrEqual(2); + expect(runOnJSTargets[runOnJSTargets.length - 1]).toBe(runOnJSTargets[runOnJSTargets.length - 2]); + + unmount(); }); }); - describe('Component structure', () => { - it('should render modal backdrop', () => { - const { getByTestId } = render(); - expect(getByTestId('modal-backdrop')).toBeTruthy(); - }); + describe('reopening', () => { + it('discards the previous zoom and pan when the modal is reopened', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 3 }); + gestures.pinch.end!(); + gestures.pan.update!({ translationX: 120, translationY: 90 }); + gestures.pan.end!(); + rerender(); + expect(readTransform(toJSON()).scale).toBe(3); + + rerender(); + rerender(); + // On device the reset effect drives the shared values straight into the native + // animation; here one more render is needed to observe the values it wrote. + rerender(); + + expect(readTransform(toJSON())).toEqual({ scale: 1, translateX: 0, translateY: 0 }); - it('should render modal content', () => { - const { getByTestId } = render(); - expect(getByTestId('modal-content')).toBeTruthy(); + unmount(); }); + }); + + describe('close button visibility', () => { + it('shows the close button at full opacity when the image is not zoomed', () => { + const { toJSON, unmount } = render(); + + expect(readCloseButtonOpacity(toJSON())).toBe(1); - it('should render close button container', () => { - const { getByTestId } = render(); - expect(getByTestId('close-button-container')).toBeTruthy(); + unmount(); }); - it('should render image container', () => { - const { getByTestId } = render(); - expect(getByTestId('image-container')).toBeTruthy(); + it('fades the close button out of the way once the image is zoomed', () => { + const { rerender, toJSON, unmount } = render(); + + gestures.pinch.update!({ scale: 4 }); + rerender(); + + expect(readCloseButtonOpacity(toJSON())).toBeLessThan(1); + + unmount(); }); }); }); diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index db9b1edc..e6c74e52 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -1,11 +1,10 @@ import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Animated, Platform, ScrollView, StyleSheet } from 'react-native'; +import { Animated, ScrollView } from 'react-native'; 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'; @@ -13,6 +12,7 @@ import { getTimeAgoUtc, invertColor } from '@/lib/utils'; import { type CallPriorityResultData } from '@/models/v4/callPriorities/callPriorityResultData'; import type { CallResultData } from '@/models/v4/calls/callResultData'; import type { DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData'; +import { stripHtml } from '@/utils/strip-html'; function getColor(call: CallResultData, priority: CallPriorityResultData | undefined) { if (!call) { @@ -39,6 +39,10 @@ export const CallCard: React.FC = React.memo(({ call, priority, s const textColor = invertColor(getColor(call, priority), true); const pulseAnim = useRef(new Animated.Value(1)).current; const destinationLabel = call.DestinationName || call.DestinationAddress || ''; + // Nature is server-authored HTML; render it as stripped plain text here — a + // WebView-backed HtmlRenderer per list row is far too heavy. The call detail + // screen keeps the full HTML rendering. + const natureText = useMemo(() => stripHtml(call.Nature), [call.Nature]); useEffect(() => { if (isTimerOverdue) { @@ -174,20 +178,13 @@ export const CallCard: React.FC = React.memo(({ call, priority, s
{/* Nature of Call */} - {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. - - + {natureText ? ( + + + {natureText} + ) : null} ); }); -const styles = StyleSheet.create({ - container: { - width: '100%', - backgroundColor: 'transparent', - }, -}); diff --git a/src/components/calls/call-files-modal.tsx b/src/components/calls/call-files-modal.tsx index 35c150f2..b6d885f4 100644 --- a/src/components/calls/call-files-modal.tsx +++ b/src/components/calls/call-files-modal.tsx @@ -17,6 +17,7 @@ import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { logger } from '@/lib/logging'; import { type CallFileResultData } from '@/models/v4/callFiles/callFileResultData'; import { useCallDetailStore } from '@/stores/calls/detail-store'; @@ -153,7 +154,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, dialogTitle: file.Name || file.FileName, }); } else { - Alert.alert(t('calls.files.share_error'), 'Sharing is not available on this device'); + Alert.alert(t('calls.files.share_error'), t('calls.files.sharing_unavailable')); } setDownloadingFiles((prev) => { @@ -162,8 +163,8 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, return newState; }); } catch (error) { - console.error('Error downloading file:', error); - Alert.alert(t('calls.files.open_error'), error instanceof Error ? error.message : 'Unknown error occurred'); + logger.error({ message: 'Error downloading call file', context: { error, callId, fileId: file.Id } }); + Alert.alert(t('calls.files.open_error'), error instanceof Error ? error.message : t('common.unknown_error')); setDownloadingFiles((prev) => { const newState = { ...prev }; delete newState[file.Id]; @@ -193,7 +194,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, {formatFileSize(file.Size)} - {file.Timestamp && {formatDate(file.Timestamp)}} + {file.Timestamp ? {formatDate(file.Timestamp)} : null}
@@ -254,7 +255,7 @@ export const CallFilesModal: React.FC = ({ isOpen, onClose, return ( <> ) : ( - - {/* Everyone Option */} - - - - - {selection.everyone ? : null} - - - {t('calls.everyone')} - {t('calls.dispatch_to_everyone')} - - - - - - {/* Users Section */} - {filteredData.users.length > 0 ? ( - - - {t('calls.users')} ({filteredData.users.length}) - - {filteredData.users.map((user) => ( - - toggleUser(user.Id)}> - - - {selection.users.includes(user.Id) ? : null} - - - {user.Name} - - - - - ))} - - ) : null} - - {/* Groups Section */} - {filteredData.groups.length > 0 ? ( - - - {t('calls.groups')} ({filteredData.groups.length}) - - {filteredData.groups.map((group) => ( - - toggleGroup(group.Id)}> - - - {selection.groups.includes(group.Id) ? : null} - - - {group.Name} - - - - - ))} - - ) : null} - - {/* Roles Section */} - {filteredData.roles.length > 0 ? ( - - - {t('calls.roles')} ({filteredData.roles.length}) - - {filteredData.roles.map((role) => ( - - toggleRole(role.Id)}> - - - {selection.roles.includes(role.Id) ? : null} - - - {role.Name} - - - - - ))} - - ) : null} - - {/* Units Section */} - {filteredData.units.length > 0 ? ( - - - {t('calls.units')} ({filteredData.units.length}) - - {filteredData.units.map((unit) => ( - - toggleUnit(unit.Id)}> - - - {selection.units.includes(unit.Id) ? : null} - - - {unit.Name} - - - - - ))} - - ) : null} - - {/* No Results */} - {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 ? ( - - {t('common.no_results_found')} - - ) : null} - + )} {/* Footer */} - {getSelectionCount()} {t('calls.selected')} + {selectionCount} {t('calls.selected')} - diff --git a/src/components/calls/full-screen-image-modal.tsx b/src/components/calls/full-screen-image-modal.tsx index b1e955a5..dcbead95 100644 --- a/src/components/calls/full-screen-image-modal.tsx +++ b/src/components/calls/full-screen-image-modal.tsx @@ -1,7 +1,7 @@ import { XIcon } from 'lucide-react-native'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Dimensions, StatusBar, TouchableOpacity } from 'react-native'; +import { StatusBar, TouchableOpacity, useWindowDimensions } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { interpolate, runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -16,11 +16,12 @@ interface FullScreenImageModalProps { imageName?: string; } -const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); - const FullScreenImageModal: React.FC = ({ isOpen, onClose, imageSource, imageName }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); + // Read live: a module-level Dimensions.get('window') keeps the pre-rotation size + // forever, which leaves the pan clamping bounded to the wrong screen. + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); // Animation values const scale = useSharedValue(1); @@ -80,24 +81,32 @@ const FullScreenImageModal: React.FC = ({ isOpen, onC savedTranslateY.value = translateY.value; }); + // runOnJS can only schedule a stable JS-thread function; a closure created inside the + // worklet cannot be transferred, so the toggle lives out here and takes the scale it + // needs as an argument. + const handleDoubleTap = React.useCallback( + (currentScale: number) => { + if (currentScale > 1) { + // Reset to original size + scale.value = withTiming(1); + translateX.value = withTiming(0); + translateY.value = withTiming(0); + savedScale.value = 1; + savedTranslateX.value = 0; + savedTranslateY.value = 0; + } else { + // Zoom in to 2x + scale.value = withTiming(2); + savedScale.value = 2; + } + }, + [scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY] + ); + const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onEnd(() => { - runOnJS(() => { - if (scale.value > 1) { - // Reset to original size - scale.value = withTiming(1); - translateX.value = withTiming(0); - translateY.value = withTiming(0); - savedScale.value = 1; - savedTranslateX.value = 0; - savedTranslateY.value = 0; - } else { - // Zoom in to 2x - scale.value = withTiming(2); - savedScale.value = 2; - } - })(); + runOnJS(handleDoubleTap)(scale.value); }); const composedGesture = Gesture.Simultaneous(Gesture.Simultaneous(pinchGesture, panGesture), doubleTapGesture); diff --git a/src/components/chat/__tests__/message-bubble.test.tsx b/src/components/chat/__tests__/message-bubble.test.tsx new file mode 100644 index 00000000..628904a4 --- /dev/null +++ b/src/components/chat/__tests__/message-bubble.test.tsx @@ -0,0 +1,138 @@ +/** + * The conversation list re-renders on every chat-store update (typing, presence, a + * sibling message arriving). Before MessageBubble was memoized, each of those repainted + * every visible bubble and re-ran linkifySegments per bubble. The memo only holds if + * callers pass stable handlers, which the channel screen now does via useCallback. + */ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/api/chat/chat', () => ({ + getChatAttachmentImageSource: (id: string) => ({ uri: `https://example.test/${id}` }), +})); + +jest.mock('expo-image', () => { + const React = require('react'); + const { View } = require('react-native'); + return { Image: (props: Record) => React.createElement(View, props) }; +}); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref })); + return new Proxy({}, { get: () => icon }); +}); + +// formatShortTime runs once per bubble render, so its call count is a render counter. +const mockFormatShortTime = jest.fn((_iso?: string | null) => '10:00'); +const mockLinkifySegments = jest.fn((body: string) => jest.requireActual('../chat-utils').linkifySegments(body)); + +jest.mock('../chat-utils', () => ({ + ...jest.requireActual('../chat-utils'), + formatShortTime: (iso?: string | null) => mockFormatShortTime(iso), + linkifySegments: (body: string) => mockLinkifySegments(body), +})); + +import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat'; + +import { MessageBubble } from '../message-bubble'; + +const buildMessage = (overrides: Partial = {}): ChatMessageResultData => + ({ + ChatMessageId: 'm1', + ChatChannelId: 'c1', + SenderUserId: 'user-2', + SenderDisplayName: 'Engine 6', + Body: 'Arriving on scene', + MessageType: ChatMessageType.Text, + Priority: ChatMessagePriority.Normal, + SentOn: '2024-01-01T10:00:00Z', + ThreadReplyCount: 0, + Reactions: [], + Attachments: [], + ...overrides, + }) as ChatMessageResultData; + +const stableHandlers = { + onLongPress: jest.fn(), + onToggleReaction: jest.fn(), + onRetry: jest.fn(), +}; + +describe('MessageBubble', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the message body', () => { + const { unmount } = render(); + + expect(screen.getByText('Arriving on scene')).toBeTruthy(); + + unmount(); + }); + + it('does not re-render when the parent repaints with identical props', () => { + const message = buildMessage(); + const element = ; + + const { rerender, unmount } = render(element); + expect(mockFormatShortTime).toHaveBeenCalledTimes(1); + + // Same message object and same handler identities: the memo must short-circuit. + rerender(element); + rerender(element); + + expect(mockFormatShortTime).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('re-renders when its own message changes', () => { + const { rerender, unmount } = render(); + expect(mockFormatShortTime).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mockFormatShortTime).toHaveBeenCalledTimes(2); + expect(screen.getByText('Clearing scene')).toBeTruthy(); + + unmount(); + }); + + it('re-runs linkify only when the body text changes', () => { + const message = buildMessage({ Body: 'See https://resgrid.com for details' }); + const element = ; + + const { rerender, unmount } = render(element); + const afterFirstRender = mockLinkifySegments.mock.calls.length; + expect(afterFirstRender).toBeGreaterThan(0); + + // A reaction change re-renders the bubble but must not re-scan the unchanged body. + rerender(); + + expect(mockLinkifySegments.mock.calls.length).toBe(afterFirstRender); + + unmount(); + }); + + it('renders a fresh inline handler as a prop change, confirming the memo compares identities', () => { + const message = buildMessage(); + + const { rerender, unmount } = render( undefined} />); + expect(mockFormatShortTime).toHaveBeenCalledTimes(1); + + // This is exactly what the channel screen used to do per item; it defeats the memo, + // which is why onRetry is now a useCallback there. + rerender( undefined} />); + + expect(mockFormatShortTime).toHaveBeenCalledTimes(2); + + unmount(); + }); +}); diff --git a/src/components/chat/message-bubble.tsx b/src/components/chat/message-bubble.tsx index 1bdf0569..d23f8a65 100644 --- a/src/components/chat/message-bubble.tsx +++ b/src/components/chat/message-bubble.tsx @@ -27,7 +27,7 @@ interface MessageBubbleProps { onPressImage?: (uri: string) => void; } -export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { +function MessageBubbleComponent({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) { const { t } = useTranslation(); // Realtime payloads omit empty collections; the store normalizes them, but messages @@ -43,6 +43,10 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon return Array.from(map.entries()); }, [message.Reactions, currentUserId]); + // Linkify is regex work over the body; cache it so re-renders (reactions, status + // changes elsewhere in the list) don't re-scan every visible bubble. + const linkSegments = useMemo(() => linkifySegments(message.Body ?? ''), [message.Body]); + // System message: centered, subtle. if (message.MessageType === ChatMessageType.System) { return ( @@ -102,7 +106,7 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon } // Text (with inline links). - const segments = linkifySegments(message.Body ?? ''); + const segments = linkSegments; if (segments.length === 0) return {message.Body}; return ( @@ -183,3 +187,8 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon ); } + +// Memoized: the conversation list re-renders on every store update (typing, presence, +// reactions); unchanged bubbles must not re-render. Callers must pass stable handlers. +export const MessageBubble = React.memo(MessageBubbleComponent); +MessageBubble.displayName = 'MessageBubble'; diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx index 3bda5d92..b7ce7ee4 100644 --- a/src/components/contacts/contact-details-sheet.tsx +++ b/src/components/contacts/contact-details-sheet.tsx @@ -220,9 +220,9 @@ export const ContactDetailsSheet: React.FC = () => { const middleName = selectedContact.MiddleName?.trim() || ''; const lastName = selectedContact.LastName?.trim() || ''; const fullName = [firstName, middleName, lastName].filter(Boolean).join(' '); - return fullName || selectedContact.Name || 'Unknown Person'; + return fullName || selectedContact.Name || t('contacts.unknown_person'); } else { - return selectedContact.CompanyName?.trim() || selectedContact.Name || 'Unknown Company'; + return selectedContact.CompanyName?.trim() || selectedContact.Name || t('contacts.unknown_company'); } }; diff --git a/src/components/maps/__tests__/full-screen-location-picker.test.tsx b/src/components/maps/__tests__/full-screen-location-picker.test.tsx index 73c9d9b6..56cfcbd7 100644 --- a/src/components/maps/__tests__/full-screen-location-picker.test.tsx +++ b/src/components/maps/__tests__/full-screen-location-picker.test.tsx @@ -1,15 +1,597 @@ -import { describe, expect, it } from '@jest/globals'; +/** + * Renders the REAL FullScreenLocationPicker. + * + * The previous suite replaced the module with `() => null` and then asserted that the + * replacement was defined, so none of the 284 lines under test ever executed. Only the + * component's dependencies are mocked here (Mapbox, expo-location, the department map + * centre, i18n and the icon set) — the picker itself is the real thing. + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import React from 'react'; +import { ActivityIndicator } from 'react-native'; -// Mock the component since it uses Mapbox which may not be available in tests -jest.mock('../full-screen-location-picker', () => ({ - __esModule: true, - default: () => null, +// ── Native / module mocks (must precede the subject import) ────────────────── + +jest.mock('@/lib/env', () => ({ + Env: { + UNIT_MAPBOX_PUBKEY: 'test-mapbox-key', + }, +})); + +const mockDepartmentCenter = { latitude: 39.14086268299356, longitude: -119.7583809782715, zoomLevel: 9 }; +jest.mock('@/lib/map-center', () => ({ + getDepartmentMapCenter: jest.fn(() => mockDepartmentCenter), })); +jest.mock('expo-location', () => ({ + Accuracy: { Lowest: 1, Low: 2, Balanced: 3, High: 4, Highest: 5, BestForNavigation: 6 }, + requestForegroundPermissionsAsync: jest.fn(), + getCurrentPositionAsync: jest.fn(), + reverseGeocodeAsync: jest.fn(), +})); + +interface MockMapProps { + children?: React.ReactNode; + [key: string]: unknown; +} + +// The camera is driven imperatively by the component, so the mock has to expose a ref +// with setCamera — that is the only way the picker can move the map. +const mockSetCamera = jest.fn(); + +jest.mock('@/components/maps/mapbox', () => { + const React = require('react'); + const { View } = require('react-native'); + + const MapView = React.forwardRef(({ children, ...props }: MockMapProps, ref: unknown) => ( + + {children} + + )); + MapView.displayName = 'MockMapView'; + + const Camera = React.forwardRef((props: MockMapProps, ref: any) => { + React.useImperativeHandle(ref, () => ({ setCamera: mockSetCamera })); + return ; + }); + Camera.displayName = 'MockCamera'; + + const PointAnnotation = ({ children, ...props }: MockMapProps) => ( + + {children} + + ); + + return { + __esModule: true, + default: { + MapView, + Camera, + PointAnnotation, + setAccessToken: jest.fn(), + StyleURL: { Dark: 'dark', Street: 'street' }, + }, + }; +}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('lucide-react-native', () => { + const { View } = require('react-native'); + return { + LocateIcon: (props: Record) => , + MapPinIcon: (props: Record) => , + XIcon: (props: Record) => , + }; +}); + +import * as Location from 'expo-location'; + +import { logger } from '@/lib/logging'; + +import FullScreenLocationPicker from '../full-screen-location-picker'; + +const mockRequestPermissions = Location.requestForegroundPermissionsAsync as unknown as jest.Mock; +const mockGetCurrentPosition = Location.getCurrentPositionAsync as unknown as jest.Mock; +const mockReverseGeocode = Location.reverseGeocodeAsync as unknown as jest.Mock; +const mockLoggerWarn = logger.warn as unknown as jest.Mock; +const mockLoggerError = logger.error as unknown as jest.Mock; + +/** Accessible names for the icon-only controls (the i18n mock echoes the key). */ +const CLOSE_LABEL = 'common.close'; +const MY_LOCATION_LABEL = 'common.get_my_location'; + +/** Mirrors LOCATION_TIMEOUT in the component. */ +const LOCATION_TIMEOUT_MS = 10000; + +let setTimeoutSpy: jest.SpyInstance; +let clearTimeoutSpy: jest.SpyInstance; + +/** + * Counts the component's own still-armed LOCATION_TIMEOUT timers. + * + * `jest.getTimerCount()` is unusable here: it also counts React Native internals, so it + * reports a non-zero baseline that has nothing to do with this component. Matching on the + * 10s delay isolates the timer under test and keeps the assertion honest. + */ +const armedLocationTimers = (): unknown[] => { + const armed = setTimeoutSpy.mock.calls.map((call, index) => ({ delay: call[1], id: setTimeoutSpy.mock.results[index]?.value })).filter((entry) => entry.delay === LOCATION_TIMEOUT_MS); + const cleared = new Set(clearTimeoutSpy.mock.calls.map((call) => call[0])); + return armed.filter((entry) => !cleared.has(entry.id)).map((entry) => entry.id); +}; + +/** A GeoJSON Point feature shaped the way Mapbox hands one to onPress. */ +const pointFeature = (longitude: number, latitude: number) => + ({ + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates: [longitude, latitude] }, + }) as unknown as GeoJSON.Feature; + +const INITIAL_LOCATION = { latitude: 40.7128, longitude: -74.006 }; + describe('FullScreenLocationPicker', () => { - it('should be importable', () => { - // This is a basic test to ensure the module can be imported - const FullScreenLocationPicker = require('../full-screen-location-picker').default; - expect(FullScreenLocationPicker).toBeDefined(); + beforeEach(() => { + jest.clearAllMocks(); + // Fake timers let the 10s LOCATION_TIMEOUT be exercised directly, and make the pending + // timer set an observable — which is how the leak tests below assert cleanup. + jest.useFakeTimers(); + // Installed after useFakeTimers so the spies wrap the fake implementations. + setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + mockRequestPermissions.mockResolvedValue({ status: 'granted' }); + mockGetCurrentPosition.mockResolvedValue({ coords: { latitude: 51.5074, longitude: -0.1278 } }); + mockReverseGeocode.mockResolvedValue([{ street: '123 Main St', name: '123 Main St', city: 'Springfield', region: 'IL', country: 'USA', postalCode: '62704' }]); + }); + + afterEach(() => { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + jest.useRealTimers(); + }); + + describe('initial location handling', () => { + it('opens on the supplied initial location at street zoom and skips the device fix', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006 })); + + expect(screen.getByTestId('map-camera').props.defaultSettings).toEqual({ + centerCoordinate: [-74.006, 40.7128], + zoomLevel: 15, + }); + expect(screen.getByTestId('map-point-annotation').props.coordinate).toEqual([-74.006, 40.7128]); + expect(screen.getByText('40.712800, -74.006000')).toBeTruthy(); + // A known starting point means no device lookup and no "tap the map" nag. + expect(mockRequestPermissions).not.toHaveBeenCalled(); + expect(screen.queryByText('common.tap_map_to_select')).toBeNull(); + + unmount(); + }); + + it('flies the camera to the initial location imperatively', async () => { + const { unmount } = render(); + + await waitFor(() => + expect(mockSetCamera).toHaveBeenCalledWith({ + centerCoordinate: [-74.006, 40.7128], + zoomLevel: 15, + animationDuration: 1000, + }) + ); + + unmount(); + }); + + it('never passes centerCoordinate as a Camera prop', async () => { + // Regression guard: driving the camera by prop as well as imperatively made every + // map tap re-fly the camera to the tapped point, fighting the pan gesture. + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + expect(screen.getByTestId('map-camera').props.centerCoordinate).toBeUndefined(); + + unmount(); + }); + + it('falls back to the department map centre at overview zoom when no initial location is given', async () => { + // Never resolving: keeps the picker in its pre-fix state so the fallback is observable. + mockGetCurrentPosition.mockImplementation(() => new Promise(() => {})); + + const { unmount } = render(); + + await waitFor(() => expect(mockRequestPermissions).toHaveBeenCalled()); + + expect(screen.getByTestId('map-camera').props.defaultSettings).toEqual({ + centerCoordinate: [mockDepartmentCenter.longitude, mockDepartmentCenter.latitude], + zoomLevel: 4, + }); + expect(screen.getByText('39.140863, -119.758381')).toBeTruthy(); + expect(screen.getByText('common.tap_map_to_select')).toBeTruthy(); + + unmount(); + }); + + it('adopts the device fix when no initial location is given', async () => { + const { unmount } = render(); + + expect(await screen.findByText('51.507400, -0.127800')).toBeTruthy(); + expect(mockGetCurrentPosition).toHaveBeenCalledWith({ accuracy: Location.Accuracy.Balanced }); + expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 51.5074, longitude: -0.1278 }); + expect(mockSetCamera).toHaveBeenCalledWith({ centerCoordinate: [-0.1278, 51.5074], zoomLevel: 15, animationDuration: 1000 }); + // Hint clears once a real position is known. + expect(screen.queryByText('common.tap_map_to_select')).toBeNull(); + + unmount(); + }); + + it('keeps the department centre when the location permission is denied', async () => { + mockRequestPermissions.mockResolvedValue({ status: 'denied' }); + + const { unmount } = render(); + + // Wait for isLocating to fall back to false: that only happens once getUserLocation + // has run to completion, so a bare "not called" check cannot pass by racing ahead. + await waitFor(() => expect(screen.getByTestId('locate-icon')).toBeTruthy()); + expect(mockRequestPermissions).toHaveBeenCalledTimes(1); + expect(mockGetCurrentPosition).not.toHaveBeenCalled(); + expect(screen.getByText('39.140863, -119.758381')).toBeTruthy(); + expect(screen.getByText('common.tap_map_to_select')).toBeTruthy(); + + unmount(); + }); + + it('logs a denied permission at warn, not error', async () => { + // A user declining the prompt is an expected outcome, not a fault to page Sentry with. + mockRequestPermissions.mockResolvedValue({ status: 'denied' }); + + const { unmount } = render(); + + await waitFor(() => expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Location permission not granted') }))); + expect(mockLoggerError).not.toHaveBeenCalled(); + + unmount(); + }); + + it('gives up on a device fix that never returns once the 10s timeout elapses', async () => { + mockGetCurrentPosition.mockImplementation(() => new Promise(() => {})); + + const { unmount } = render(); + + await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalled()); + // Spinner is up while the fix is outstanding. + expect(screen.queryByTestId('locate-icon')).toBeNull(); + + await act(async () => { + jest.advanceTimersByTime(10000); + }); + + // A slow fix is transient, so it logs at warn rather than as a Sentry error. + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Timed out getting the device location') })); + expect(mockLoggerError).not.toHaveBeenCalled(); + // Button becomes usable again and the map stays on the department centre. + expect(screen.getByTestId('locate-icon')).toBeTruthy(); + expect(screen.getByText('39.140863, -119.758381')).toBeTruthy(); + + unmount(); + }); + + it('keeps the department centre when the device fix fails', async () => { + mockGetCurrentPosition.mockRejectedValue(new Error('no gps')); + + const { unmount } = render(); + + // An unexpected platform failure is a genuine error and must reach Sentry. + await waitFor(() => expect(mockLoggerError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Failed to get the device location') }))); + + expect(screen.getByText('39.140863, -119.758381')).toBeTruthy(); + // The spinner must stop even on the failure path, or the button stays dead. + expect(screen.getByTestId('locate-icon')).toBeTruthy(); + + unmount(); + }); + }); + + describe('location timeout cleanup', () => { + // The 10s timer holds a closure over the component. Every path that leaves + // getUserLocation must disarm it, or failed attempts pile up live timers. + + it('leaves no pending timer when the device fix rejects', async () => { + mockGetCurrentPosition.mockRejectedValue(new Error('no gps')); + + const { unmount } = render(); + + await waitFor(() => expect(mockLoggerError).toHaveBeenCalled()); + + // The timer was armed before getCurrentPositionAsync rejected, so this is not vacuous. + expect(setTimeoutSpy.mock.calls.some((call) => call[1] === LOCATION_TIMEOUT_MS)).toBe(true); + expect(armedLocationTimers()).toEqual([]); + + unmount(); + }); + + it('does not stack timers across repeated failed attempts', async () => { + mockGetCurrentPosition.mockRejectedValue(new Error('no gps')); + + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + for (let attempt = 1; attempt <= 3; attempt += 1) { + fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL)); + await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalledTimes(attempt)); + await act(async () => {}); + } + + // Three failed attempts armed three timers; none may still be live. + expect(setTimeoutSpy.mock.calls.filter((call) => call[1] === LOCATION_TIMEOUT_MS)).toHaveLength(3); + expect(armedLocationTimers()).toEqual([]); + + unmount(); + }); + + it('clears a still-armed timer when the picker unmounts mid-lookup', async () => { + // Never resolves, so the timer is genuinely outstanding at unmount. + mockGetCurrentPosition.mockImplementation(() => new Promise(() => {})); + + const { unmount } = render(); + + await waitFor(() => expect(mockGetCurrentPosition).toHaveBeenCalled()); + // Guard the guard: if nothing were armed, the assertion after unmount would be vacuous. + expect(armedLocationTimers()).toHaveLength(1); + + unmount(); + + expect(armedLocationTimers()).toEqual([]); + }); + }); + + describe('map interaction', () => { + it('moves the selected coordinate and marker to the tapped point', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006 })); + + fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749)); + + expect(await screen.findByText('37.774900, -122.419400')).toBeTruthy(); + expect(screen.getByTestId('map-point-annotation').props.coordinate).toEqual([-122.4194, 37.7749]); + expect(mockReverseGeocode).toHaveBeenCalledWith({ latitude: 37.7749, longitude: -122.4194 }); + + unmount(); + }); + + it('does not re-fly the camera on a map tap', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockSetCamera).toHaveBeenCalledTimes(1)); + + fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749)); + await screen.findByText('37.774900, -122.419400'); + + expect(mockSetCamera).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('clears the "tap the map" hint once the user has tapped', async () => { + mockGetCurrentPosition.mockImplementation(() => new Promise(() => {})); + + const { unmount } = render(); + + await waitFor(() => expect(screen.getByText('common.tap_map_to_select')).toBeTruthy()); + + fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(10.5, 20.25)); + + expect(await screen.findByText('20.250000, 10.500000')).toBeTruthy(); + expect(screen.queryByText('common.tap_map_to_select')).toBeNull(); + + unmount(); + }); + + it('ignores a press whose geometry carries no coordinates', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalledTimes(1)); + + fireEvent(screen.getByTestId('map-view'), 'press', { + type: 'Feature', + properties: {}, + geometry: { type: 'GeometryCollection', geometries: [] }, + } as unknown as GeoJSON.Feature); + + expect(screen.getByText('40.712800, -74.006000')).toBeTruthy(); + expect(mockReverseGeocode).toHaveBeenCalledTimes(1); + + unmount(); + }); + }); + + describe('address lookup', () => { + it('renders the joined address and drops the duplicate name/street part', async () => { + const { unmount } = render(); + + expect(await screen.findByText('123 Main St, Springfield, IL, 62704, USA')).toBeTruthy(); + + unmount(); + }); + + it('keeps a place name that differs from the street', async () => { + mockReverseGeocode.mockResolvedValue([{ street: '1 Infinite Loop', name: 'Apple Park', city: 'Cupertino', region: 'CA', country: 'USA', postalCode: '95014' }]); + + const { unmount } = render(); + + expect(await screen.findByText('1 Infinite Loop, Apple Park, Cupertino, CA, 95014, USA')).toBeTruthy(); + + unmount(); + }); + + it('shows the loading placeholder while the lookup is in flight', async () => { + let resolveGeocode: (value: unknown) => void = () => {}; + mockReverseGeocode.mockImplementation(() => new Promise((resolve) => (resolveGeocode = resolve))); + + const { unmount } = render(); + + expect(await screen.findByText('common.loading_address')).toBeTruthy(); + + resolveGeocode([{ city: 'Springfield' }]); + + expect(await screen.findByText('Springfield')).toBeTruthy(); + expect(screen.queryByText('common.loading_address')).toBeNull(); + + unmount(); + }); + + it('reports no address when the lookup comes back empty', async () => { + mockReverseGeocode.mockResolvedValue([]); + + const { unmount } = render(); + + expect(await screen.findByText('common.no_address_found')).toBeTruthy(); + + unmount(); + }); + + it('reports no address when the lookup throws', async () => { + mockReverseGeocode.mockRejectedValue(new Error('offline')); + + const { unmount } = render(); + + expect(await screen.findByText('common.no_address_found')).toBeTruthy(); + // Offline/geocoder-down is transient and the UI degrades gracefully, so warn not error. + expect(mockLoggerWarn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('Reverse geocode failed') })); + expect(mockLoggerError).not.toHaveBeenCalled(); + + unmount(); + }); + }); + + describe('confirm and cancel', () => { + it('hands back the tapped coordinate with its resolved address and then closes', async () => { + const onLocationSelected = jest.fn(); + const onClose = jest.fn(); + const { unmount } = render(); + + await screen.findByText('123 Main St, Springfield, IL, 62704, USA'); + + mockReverseGeocode.mockResolvedValue([{ city: 'San Francisco', region: 'CA' }]); + fireEvent(screen.getByTestId('map-view'), 'press', pointFeature(-122.4194, 37.7749)); + await screen.findByText('San Francisco, CA'); + + fireEvent.press(screen.getByText('common.set_location')); + + expect(onLocationSelected).toHaveBeenCalledWith({ + latitude: 37.7749, + longitude: -122.4194, + address: 'San Francisco, CA', + }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it('reports an undefined address when none could be resolved', async () => { + mockReverseGeocode.mockResolvedValue([]); + const onLocationSelected = jest.fn(); + const { unmount } = render(); + + await screen.findByText('common.no_address_found'); + + fireEvent.press(screen.getByText('common.set_location')); + + expect(onLocationSelected).toHaveBeenCalledWith({ latitude: 40.7128, longitude: -74.006, address: undefined }); + + unmount(); + }); + + it('closes without selecting anything when the close button is pressed', async () => { + const onLocationSelected = jest.fn(); + const onClose = jest.fn(); + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + fireEvent.press(screen.getByLabelText(CLOSE_LABEL)); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onLocationSelected).not.toHaveBeenCalled(); + + unmount(); + }); + }); + + describe('accessibility', () => { + it('gives both icon-only controls an accessible name and button role', async () => { + // Without these, a screen-reader user hears "button" twice with no way to tell the + // destructive close apart from the harmless re-locate. + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + for (const label of [CLOSE_LABEL, MY_LOCATION_LABEL]) { + expect(screen.getByLabelText(label).props.accessibilityRole).toBe('button'); + } + + unmount(); + }); + }); + + describe('my-location button', () => { + it('re-runs the device lookup and recentres on the result', async () => { + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + expect(mockGetCurrentPosition).not.toHaveBeenCalled(); + + fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL)); + + expect(await screen.findByText('51.507400, -0.127800')).toBeTruthy(); + expect(mockSetCamera).toHaveBeenLastCalledWith({ centerCoordinate: [-0.1278, 51.5074], zoomLevel: 15, animationDuration: 1000 }); + + unmount(); + }); + + it('swaps the icon for a spinner and blocks re-entry while locating', async () => { + let resolvePosition: (value: unknown) => void = () => {}; + mockGetCurrentPosition.mockImplementation(() => new Promise((resolve) => (resolvePosition = resolve))); + + const { unmount } = render(); + + await waitFor(() => expect(mockReverseGeocode).toHaveBeenCalled()); + + fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL)); + // requestForegroundPermissionsAsync is reached synchronously, so its call count is a + // direct, non-racy witness that a lookup was actually started. + expect(mockRequestPermissions).toHaveBeenCalledTimes(1); + + await waitFor(() => expect(screen.queryByTestId('locate-icon')).toBeNull()); + expect(screen.UNSAFE_getByType(ActivityIndicator)).toBeTruthy(); + expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1); + + // The button keeps its accessible name while the spinner occupies it, so this presses + // the live control — a press that `disabled` must swallow. + fireEvent.press(screen.getByLabelText(MY_LOCATION_LABEL)); + expect(mockRequestPermissions).toHaveBeenCalledTimes(1); + await act(async () => {}); + expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1); + + resolvePosition({ coords: { latitude: 51.5074, longitude: -0.1278 } }); + expect(await screen.findByTestId('locate-icon')).toBeTruthy(); + + unmount(); + }); }); -}); \ No newline at end of file +}); diff --git a/src/components/maps/__tests__/map-pins.test.tsx b/src/components/maps/__tests__/map-pins.test.tsx new file mode 100644 index 00000000..83201049 --- /dev/null +++ b/src/components/maps/__tests__/map-pins.test.tsx @@ -0,0 +1,141 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData'; + +import MapPins from '../map-pins'; + +// Counts how many times each marker has been mounted. Stacking order is fixed +// when a marker attaches to the map, so "the active pin re-attaches" is only +// observable as a remount of that marker. +const mockMarkerMounts: Record = {}; + +jest.mock('@/components/maps/mapbox', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const MarkerView = ({ children, ...props }: any) => { + ReactActual.useEffect(() => { + mockMarkerMounts[props.id] = (mockMarkerMounts[props.id] ?? 0) + 1; + }, []); + return ReactActual.createElement(View, { testID: props.id, ...props }, children); + }; + return { + __esModule: true, + default: { MarkerView }, + PointAnnotation: 'PointAnnotation', + }; +}); + +jest.mock('nativewind', () => ({ + useColorScheme: jest.fn(() => ({ colorScheme: 'light' })), + cssInterop: jest.fn((Component: any) => Component), +})); + +const makePin = (overrides: Partial): MapMakerInfoData => ({ + Id: 'pin-id', + Longitude: -74.0, + Latitude: 40.7, + Title: 'Pin', + zIndex: 0, + ImagePath: 'engine_available', + InfoWindowContent: '', + Color: '', + Type: 1, + Marker: '', + PoiImage: '', + ...overrides, +}); + +describe('MapPins', () => { + beforeEach(() => { + Object.keys(mockMarkerMounts).forEach((key) => delete mockMarkerMounts[key]); + }); + + const pins = [makePin({ Id: 'call-1', Type: 0, ImagePath: 'call', Title: 'Structure Fire' }), makePin({ Id: 'unit-1', Type: 1, Title: 'Engine 1' }), makePin({ Id: 'call-2', Type: 0, ImagePath: 'call', Title: 'MVA' })]; + + it('renders a marker per pin', () => { + const { getByTestId, unmount } = render(); + expect(getByTestId('pin-call-1')).toBeTruthy(); + expect(getByTestId('pin-unit-1')).toBeTruthy(); + expect(getByTestId('pin-call-2')).toBeTruthy(); + unmount(); + }); + + it('renders the active call last so it stacks above other markers', () => { + const { toJSON, unmount } = render(); + const tree = toJSON() as any[]; + const ids = tree.map((node) => node.props.testID); + expect(ids[ids.length - 1]).toBe('pin-call-1'); + expect(ids).toHaveLength(3); + unmount(); + }); + + // Marker stacking is fixed at attach time (DOM insertion order on web, an + // imperative MarkerView attach on iOS), so reordering keyed children alone + // updates the ring but never restacks. The active flag is folded into the pin + // key so the pin whose active state changed remounts and re-attaches on top. + it('remounts a pin when it becomes the active call so it re-attaches on top', () => { + const { rerender, unmount } = render(); + expect(mockMarkerMounts['pin-call-1']).toBe(1); + expect(mockMarkerMounts['pin-unit-1']).toBe(1); + + rerender(); + + // The newly active pin re-attached... + expect(mockMarkerMounts['pin-call-1']).toBe(2); + // ...and unrelated pins did not churn. + expect(mockMarkerMounts['pin-unit-1']).toBe(1); + unmount(); + }); + + it('remounts both the old and new active pin when the active call changes mid-session', () => { + const { getByTestId, getAllByTestId, rerender, unmount } = render(); + expect(mockMarkerMounts['pin-call-1']).toBe(1); + expect(mockMarkerMounts['pin-call-2']).toBe(1); + + rerender(); + + // Both change active state, so both re-attach — the new active call ends up + // attached last, above the one it replaced. + expect(mockMarkerMounts['pin-call-1']).toBe(2); + expect(mockMarkerMounts['pin-call-2']).toBe(2); + expect(mockMarkerMounts['pin-unit-1']).toBe(1); + + // The highlight ring moved with it. + expect(getAllByTestId('pin-active-ring')).toHaveLength(1); + expect(getByTestId('pin-call-2')).toBeTruthy(); + unmount(); + }); + + it('does not remount pins when an unrelated prop changes', () => { + const onPinPress = jest.fn(); + const { rerender, unmount } = render(); + const before = { ...mockMarkerMounts }; + + rerender(); + + expect(mockMarkerMounts).toEqual(before); + unmount(); + }); + + it('highlights only the active call pin', () => { + const { getAllByTestId, unmount } = render(); + expect(getAllByTestId('pin-active-ring')).toHaveLength(1); + unmount(); + }); + + it('does not highlight a unit whose id happens to match the active call id', () => { + const unitPins = [makePin({ Id: 'shared-id', Type: 1, Title: 'Engine 1' })]; + const { queryByTestId, unmount } = render(); + expect(queryByTestId('pin-active-ring')).toBeNull(); + unmount(); + }); + + it('keeps original order when there is no active call', () => { + const { toJSON, unmount } = render(); + const tree = toJSON() as any[]; + const ids = tree.map((node) => node.props.testID); + expect(ids).toEqual(['pin-call-1', 'pin-unit-1', 'pin-call-2']); + unmount(); + }); +}); diff --git a/src/components/maps/__tests__/unit-location-marker.test.tsx b/src/components/maps/__tests__/unit-location-marker.test.tsx new file mode 100644 index 00000000..a991e2fd --- /dev/null +++ b/src/components/maps/__tests__/unit-location-marker.test.tsx @@ -0,0 +1,58 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; + +import UnitLocationMarker from '../unit-location-marker'; + +jest.mock('@/components/maps/mapbox', () => { + const ReactActual = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + const passthrough = (name: string) => + Object.assign(({ children, ...props }: any) => ReactActual.createElement(View, { testID: props.id ?? name, ...props }, children), { displayName: name }); + return { + __esModule: true, + default: { + ShapeSource: passthrough('ShapeSource'), + FillLayer: passthrough('FillLayer'), + LineLayer: passthrough('LineLayer'), + CircleLayer: passthrough('CircleLayer'), + SymbolLayer: passthrough('SymbolLayer'), + Images: passthrough('Images'), + }, + }; +}); + +describe('UnitLocationMarker', () => { + it('renders the location dot', () => { + const { getByTestId, unmount } = render(); + expect(getByTestId('unit-location-dot')).toBeTruthy(); + unmount(); + }); + + it('renders the accuracy circle when accuracy is known', () => { + const { getByTestId, unmount } = render(); + expect(getByTestId('unit-location-accuracy')).toBeTruthy(); + expect(getByTestId('unit-location-accuracy-fill')).toBeTruthy(); + unmount(); + }); + + it('hides the accuracy circle when accuracy is missing or invalid', () => { + const { queryByTestId, unmount } = render(); + expect(queryByTestId('unit-location-accuracy')).toBeNull(); + unmount(); + }); + + it('renders the heading arrow rotated to the current heading', () => { + const { getByTestId, unmount } = render(); + const arrow = getByTestId('unit-location-heading'); + expect(arrow.props.style.iconRotate).toBe(90); + expect(arrow.props.style.iconRotationAlignment).toBe('map'); + unmount(); + }); + + it('hides the heading arrow when there is no heading fix', () => { + // iOS reports "no heading" as -1 + const { queryByTestId, unmount } = render(); + expect(queryByTestId('unit-location-heading')).toBeNull(); + unmount(); + }); +}); diff --git a/src/components/maps/full-screen-location-picker.tsx b/src/components/maps/full-screen-location-picker.tsx index de36504b..685419c5 100644 --- a/src/components/maps/full-screen-location-picker.tsx +++ b/src/components/maps/full-screen-location-picker.tsx @@ -2,7 +2,7 @@ import * as Location from 'expo-location'; import { LocateIcon, MapPinIcon, XIcon } from 'lucide-react-native'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Dimensions, StyleSheet, TouchableOpacity } from 'react-native'; +import { ActivityIndicator, StyleSheet, TouchableOpacity, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Mapbox from '@/components/maps/mapbox'; @@ -10,6 +10,7 @@ import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { Env } from '@/lib/env'; +import { logger } from '@/lib/logging'; import { getDepartmentMapCenter } from '@/lib/map-center'; // Ensure Mapbox access token is set before using any Mapbox components @@ -20,6 +21,9 @@ Mapbox.setAccessToken(Env.UNIT_MAPBOX_PUBKEY); // Timeout for location fetching (in milliseconds) const LOCATION_TIMEOUT = 10000; +// Distinguishes our own timeout rejection from a genuine platform failure in the catch, +// so an expected slow fix logs at warn instead of paging Sentry. +const LOCATION_TIMEOUT_MESSAGE = 'Location timeout'; interface FullScreenLocationPickerProps { initialLocation?: { @@ -33,7 +37,9 @@ interface FullScreenLocationPickerProps { const FullScreenLocationPicker: React.FC = ({ initialLocation, onLocationSelected, onClose }) => { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const mapRef = useRef>(null); + // Read live rather than at StyleSheet-create time — a module-level + // Dimensions.get('window') keeps the pre-rotation size forever. + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const cameraRef = useRef(null); // Using any due to imperative handle // Always start with a location - either initial, or default const [currentLocation, setCurrentLocation] = useState<{ @@ -45,6 +51,16 @@ const FullScreenLocationPicker: React.FC = ({ ini const [address, setAddress] = useState(undefined); const [hasUserLocation, setHasUserLocation] = useState(!!initialLocation); const isMountedRef = useRef(true); + // Held in a ref so both the `finally` below and the unmount cleanup can cancel a pending + // timer. Left uncleared, every failed fix keeps a 10s closure over this component alive. + const locationTimeoutRef = useRef | undefined>(undefined); + + const clearLocationTimeout = React.useCallback(() => { + if (locationTimeoutRef.current !== undefined) { + clearTimeout(locationTimeoutRef.current); + locationTimeoutRef.current = undefined; + } + }, []); const reverseGeocode = React.useCallback(async (latitude: number, longitude: number) => { if (!isMountedRef.current) return; @@ -74,7 +90,8 @@ const FullScreenLocationPicker: React.FC = ({ ini setAddress(undefined); } } catch (error) { - console.error('Error reverse geocoding:', error); + // Transient (offline / geocoder unavailable) and the UI degrades to "no address found". + logger.warn({ message: 'Reverse geocode failed for the location picker', context: { error, latitude, longitude } }); if (isMountedRef.current) setAddress(undefined); } finally { if (isMountedRef.current) setIsReverseGeocoding(false); @@ -88,14 +105,16 @@ const FullScreenLocationPicker: React.FC = ({ ini try { const { status } = await Location.requestForegroundPermissionsAsync(); if (status !== 'granted') { - console.error('Location permission not granted'); + // Expected outcome of a user choice, not a fault — the picker falls back to the map centre. + logger.warn({ message: 'Location permission not granted for the location picker', context: { status } }); return; } - // Create a timeout promise with cleanup - let timeoutId: ReturnType | undefined; + // Any previous attempt's timer is cancelled before arming a new one, so repeated + // "my location" taps cannot stack timers. + clearLocationTimeout(); const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Location timeout')), LOCATION_TIMEOUT); + locationTimeoutRef.current = setTimeout(() => reject(new Error(LOCATION_TIMEOUT_MESSAGE)), LOCATION_TIMEOUT); }); // Race between getting location and timeout @@ -106,9 +125,6 @@ const FullScreenLocationPicker: React.FC = ({ ini timeoutPromise, ]); - // Clear timeout if location resolved first - if (timeoutId !== undefined) clearTimeout(timeoutId); - if (!isMountedRef.current) return; const newLocation = { @@ -128,20 +144,39 @@ const FullScreenLocationPicker: React.FC = ({ ini }); } } catch (error) { - console.error('Error getting location:', error); // Don't update location - keep using whatever we have (initial or default) + if (error instanceof Error && error.message === LOCATION_TIMEOUT_MESSAGE) { + logger.warn({ message: 'Timed out getting the device location for the location picker', context: { timeoutMs: LOCATION_TIMEOUT } }); + } else { + logger.error({ message: 'Failed to get the device location for the location picker', context: { error } }); + } } finally { + // Runs on every path — resolved, rejected and the permission-denied early return — + // so a rejected fix can never leave the 10s timer armed. + clearLocationTimeout(); if (isMountedRef.current) setIsLocating(false); } - }, [reverseGeocode]); + }, [reverseGeocode, clearLocationTimeout]); + + // Depend on the coordinates rather than the object: `initialLocation` is a new identity on + // every parent render for any caller passing an object literal, which would re-run the effect + // and re-fly the camera, clobbering a location the user had already tapped. + const initialLatitude = initialLocation?.latitude; + const initialLongitude = initialLocation?.longitude; useEffect(() => { isMountedRef.current = true; - if (initialLocation) { - setCurrentLocation(initialLocation); + if (initialLatitude !== undefined && initialLongitude !== undefined) { + setCurrentLocation({ latitude: initialLatitude, longitude: initialLongitude }); setHasUserLocation(true); - reverseGeocode(initialLocation.latitude, initialLocation.longitude); + reverseGeocode(initialLatitude, initialLongitude); + // Camera is imperative-only, so move it here rather than through props. + cameraRef.current?.setCamera({ + centerCoordinate: [initialLongitude, initialLatitude], + zoomLevel: 15, + animationDuration: 1000, + }); } else { // Try to get user location, but don't block the map from showing getUserLocation(); @@ -149,8 +184,9 @@ const FullScreenLocationPicker: React.FC = ({ ini return () => { isMountedRef.current = false; + clearLocationTimeout(); }; - }, [initialLocation, getUserLocation, reverseGeocode]); + }, [initialLatitude, initialLongitude, getUserLocation, reverseGeocode, clearLocationTimeout]); const handleMapPress = (event: GeoJSON.Feature) => { if (event.geometry.type !== 'GeometryCollection' && 'coordinates' in event.geometry) { @@ -175,9 +211,12 @@ const FullScreenLocationPicker: React.FC = ({ ini }; return ( - - - + + + {/* Camera is driven imperatively only (see getUserLocation). Passing + centerCoordinate as well made every map tap re-fly the camera to the + tapped point and updated the camera mid-pan, fighting the gesture. */} + {/* Marker for the selected location */} @@ -187,12 +226,12 @@ const FullScreenLocationPicker: React.FC = ({ ini {/* Close button */} - + {/* My Location button */} - + {isLocating ? : } @@ -222,8 +261,6 @@ const FullScreenLocationPicker: React.FC = ({ ini const styles = StyleSheet.create({ container: { flex: 1, - width: Dimensions.get('window').width, - height: Dimensions.get('window').height, position: 'relative', }, map: { diff --git a/src/components/maps/location-picker.tsx b/src/components/maps/location-picker.tsx index 14e4cfac..0a38d2ae 100644 --- a/src/components/maps/location-picker.tsx +++ b/src/components/maps/location-picker.tsx @@ -141,7 +141,10 @@ const LocationPicker: React.FC = ({ initialLocation, onLoca return ( - + {/* Camera is driven imperatively only (see getUserLocation). Passing + centerCoordinate as well made every map tap re-fly the camera to the + tapped point and updated the camera mid-pan, fighting the gesture. */} + {/* Marker for the selected location */} diff --git a/src/components/maps/map-pins.tsx b/src/components/maps/map-pins.tsx index 39b4fed9..967facbe 100644 --- a/src/components/maps/map-pins.tsx +++ b/src/components/maps/map-pins.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import Mapbox from '@/components/maps/mapbox'; import { type MAP_ICONS } from '@/constants/map-icons'; @@ -13,6 +13,13 @@ type MapIconKey = keyof typeof MAP_ICONS; interface MapPinsProps { pins: MapMakerInfoData[]; onPinPress?: (pin: MapMakerInfoData) => void; + /** Id of the department's active call — its pin is highlighted and drawn above the others. */ + activeCallId?: string | null; +} + +/** Call markers come back with Type 0 (or the legacy 'call' image). */ +function isCallPin(pin: MapMakerInfoData): boolean { + return pin.Type === 0 || pin.ImagePath?.toLowerCase() === 'call'; } /** @@ -22,7 +29,7 @@ interface MapPinsProps { * POI markers use the SVG shape + icon rendering (per the "POI Map Icon Renderer" * reference document). Non-POI markers use PNG images from the MAP_ICONS lookup. */ -const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void }) => { +const MapPin = React.memo(({ pin, onPinPress, isActiveCall }: { pin: MapMakerInfoData; onPinPress?: (pin: MapMakerInfoData) => void; isActiveCall?: boolean }) => { const handlePress = useCallback(() => { onPinPress?.(pin); }, [onPinPress, pin]); @@ -42,7 +49,7 @@ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPr {poi ? ( ) : ( - + )} ); @@ -50,12 +57,28 @@ const MapPin = React.memo(({ pin, onPinPress }: { pin: MapMakerInfoData; onPinPr MapPin.displayName = 'MapPin'; -const MapPins: React.FC = ({ pins, onPinPress }) => { +const MapPins: React.FC = ({ pins, onPinPress, activeCallId }) => { + // Markers stack in mount order on every platform (native views and DOM + // markers alike), so rendering the active call last keeps it on top of + // overlapping pins. + const orderedPins = useMemo(() => { + if (!activeCallId) return pins; + const activeIndex = pins.findIndex((pin) => isCallPin(pin) && pin.Id === activeCallId); + if (activeIndex === -1) return pins; + return [...pins.slice(0, activeIndex), ...pins.slice(activeIndex + 1), pins[activeIndex]]; + }, [pins, activeCallId]); + return ( <> - {pins.map((pin) => ( - - ))} + {orderedPins.map((pin) => { + const isActiveCall = activeCallId != null && isCallPin(pin) && pin.Id === activeCallId; + // Stacking order is fixed when the marker attaches (DOM insertion order + // on web, imperative MarkerView attach on iOS), so reordering keyed + // children alone updates the ring but never restacks. Folding the active + // flag into the key remounts the pin whose active state changed, which + // re-attaches it on top. + return ; + })} ); }; diff --git a/src/components/maps/map-view.web.tsx b/src/components/maps/map-view.web.tsx index ec8adbd0..b0a38c46 100644 --- a/src/components/maps/map-view.web.tsx +++ b/src/components/maps/map-view.web.tsx @@ -21,6 +21,13 @@ export const MapContext = React.createContext(null); // Context to share source ID from source components (ShapeSource, ImageSource, RasterSource) to layer children const SourceContext = React.createContext(null); +// mapbox-gl's setStyle() replaces the whole style document, dropping every +// custom image, source and layer that was added on top of it. This counter is +// bumped on each 'style.load' so the add-effects below re-run and re-register +// their content after a theme swap (otherwise the unit dot, heading arrow, +// accuracy circle, route lines and geofence vanish until the map remounts). +const StyleGenerationContext = React.createContext(0); + // StyleURL constants matching native Mapbox SDK export const StyleURL = { Street: 'mapbox://styles/mapbox/streets-v12', @@ -110,6 +117,10 @@ function toSymbolLayout(style: any) { if (style?.iconAnchor !== undefined) l['icon-anchor'] = style.iconAnchor; if (style?.iconOffset !== undefined) l['icon-offset'] = style.iconOffset; if (style?.iconAllowOverlap !== undefined) l['icon-allow-overlap'] = style.iconAllowOverlap; + if (style?.iconIgnorePlacement !== undefined) l['icon-ignore-placement'] = style.iconIgnorePlacement; + if (style?.iconRotate !== undefined) l['icon-rotate'] = style.iconRotate; + if (style?.iconRotationAlignment !== undefined) l['icon-rotation-alignment'] = style.iconRotationAlignment; + if (style?.iconPitchAlignment !== undefined) l['icon-pitch-alignment'] = style.iconPitchAlignment; if (style?.symbolPlacement !== undefined) l['symbol-placement'] = style.symbolPlacement; if (style?.symbolSpacing !== undefined) l['symbol-spacing'] = style.symbolSpacing; return l; @@ -190,6 +201,17 @@ export const MapView = forwardRef( const map = useRef(null); const [isLoaded, setIsLoaded] = useState(false); const [hasSize, setHasSize] = useState(false); + // Bumped on every 'style.load' so children re-add their images/sources/layers + // after a theme swap wipes them (see StyleGenerationContext). + const [styleGeneration, setStyleGeneration] = useState(0); + + // The map is created once (deps: [hasSize]), so the 'moveend' handler would + // otherwise capture the first onCameraChanged forever. On the home map that + // callback is recreated per isMapLocked — which is MMKV-persisted — so a + // session booting locked kept a stale isMapLocked=true closure and never + // recorded user pans after unlocking. Read the latest prop through a ref. + const onCameraChangedRef = useRef(onCameraChanged); + onCameraChangedRef.current = onCameraChanged; useImperativeHandle(ref, () => ({ getMap: () => map.current, @@ -286,7 +308,13 @@ export const MapView = forwardRef( // 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 } }); + onCameraChangedRef.current?.({ properties: { isUserInteraction: wasUser } }); + }); + + // setStyle() drops every custom image/source/layer — tell the children to + // re-add theirs once the new style document is in place. + newMap.on('style.load', () => { + setStyleGeneration((generation) => generation + 1); }); map.current = newMap; @@ -395,6 +423,42 @@ export const MapView = forwardRef( return () => ro.disconnect(); }, [isLoaded]); + // Gesture handlers are set at construction, so prop changes (e.g. the home + // map's scrollEnabled={!isMapLocked}) never reached the map — locking the + // map left every gesture live on web. Keep the handlers in sync. + useEffect(() => { + const instance = map.current; + if (!instance || instance.__removed) return; + + const applyHandler = (handler: any, enabled: boolean) => { + try { + if (enabled) { + handler?.enable(); + } else { + handler?.disable(); + } + } catch { + /* handler may not exist on older mapbox-gl builds */ + } + }; + + applyHandler(instance.dragPan, scrollEnabled); + applyHandler(instance.scrollZoom, zoomEnabled); + applyHandler(instance.doubleClickZoom, zoomEnabled); + applyHandler(instance.dragRotate, rotateEnabled); + // Touch pinch drives both zoom and rotate; keep it live only while at + // least one of them is allowed. + applyHandler(instance.touchZoomRotate, zoomEnabled || rotateEnabled); + // pitchWithRotate is a dragRotate option rather than its own handler. + try { + if (instance.dragRotate && '_pitchWithRotate' in instance.dragRotate) { + instance.dragRotate._pitchWithRotate = pitchEnabled; + } + } catch { + /* ignore — option is private and may move between mapbox-gl versions */ + } + }, [isLoaded, scrollEnabled, zoomEnabled, rotateEnabled, pitchEnabled]); + // Update style when it changes useEffect(() => { if (map.current && styleURL) { @@ -423,7 +487,11 @@ export const MapView = forwardRef( minHeight: style?.height || style?.minHeight || 100, }} > - {isLoaded && {children}} + {isLoaded ? ( + + {children} + + ) : null}
); } @@ -448,162 +516,185 @@ interface CameraProps { bounds?: { ne: [number, number]; sw: [number, number] }; /** Padding for bounds fitting */ padding?: { paddingTop?: number; paddingBottom?: number; paddingLeft?: number; paddingRight?: number }; + /** Initial camera placement, matching the native Camera prop of the same name. */ + defaultSettings?: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number }; } // Camera component -export const Camera = forwardRef(({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel, bounds, padding }, ref) => { - const map = useContext(MapContext); - const geolocateControl = useRef(null); - const hasInitialized = useRef(false); +export const Camera = forwardRef( + ({ centerCoordinate, zoomLevel, heading, pitch, animationDuration = 1000, animationMode, followUserLocation, followZoomLevel, bounds, padding, defaultSettings }, ref) => { + const map = 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; + useImperativeHandle(ref, () => ({ + setCamera: (options: { centerCoordinate?: [number, number]; zoomLevel?: number; heading?: number; pitch?: number; animationDuration?: number }) => { + if (!map) return; - if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) { - return; - } + if (options.centerCoordinate && (!isFinite(options.centerCoordinate[0]) || !isFinite(options.centerCoordinate[1]))) { + return; + } - map.easeTo( - { - center: options.centerCoordinate, - zoom: options.zoomLevel, - bearing: options.heading, - pitch: options.pitch, - duration: options.animationDuration || 1000, - }, - { _programmatic: true } - ); - }, + map.easeTo( + { + center: options.centerCoordinate, + zoom: options.zoomLevel, + bearing: options.heading, + pitch: options.pitch, + duration: options.animationDuration || 1000, + }, + { _programmatic: true } + ); + }, - /** flyTo supports both array form flyTo([lng, lat], duration) and options-object form */ - flyTo: (coordinatesOrOptions: any, duration?: number) => { - if (!map) return; + /** flyTo supports both array form flyTo([lng, lat], duration) and options-object form */ + flyTo: (coordinatesOrOptions: any, duration?: number) => { + if (!map) return; - if (Array.isArray(coordinatesOrOptions)) { - // Native Mapbox Camera API: flyTo([lng, lat], animationDuration) - const [lng, lat] = coordinatesOrOptions; - if (!isFinite(lng) || !isFinite(lat)) return; - map.flyTo({ center: [lng, lat] as [number, number], duration: duration || 1000 }, { _programmatic: true }); - } else { - // Options-object form: flyTo({ center, zoom, ... }) - const opts = coordinatesOrOptions; - if (opts?.center && Array.isArray(opts.center) && (!isFinite(opts.center[0]) || !isFinite(opts.center[1]))) return; - map.flyTo(opts, { _programmatic: true }); - } - }, + if (Array.isArray(coordinatesOrOptions)) { + // Native Mapbox Camera API: flyTo([lng, lat], animationDuration) + const [lng, lat] = coordinatesOrOptions; + if (!isFinite(lng) || !isFinite(lat)) return; + map.flyTo({ center: [lng, lat] as [number, number], duration: duration || 1000 }, { _programmatic: true }); + } else { + // Options-object form: flyTo({ center, zoom, ... }) + const opts = coordinatesOrOptions; + if (opts?.center && Array.isArray(opts.center) && (!isFinite(opts.center[0]) || !isFinite(opts.center[1]))) return; + map.flyTo(opts, { _programmatic: true }); + } + }, - /** fitBounds(ne, sw, padding?, duration?) — matches native Mapbox Camera API */ - fitBounds: (ne: [number, number], sw: [number, number], pad?: number | number[], duration?: number) => { - if (!map) return; + /** fitBounds(ne, sw, padding?, duration?) — matches native Mapbox Camera API */ + fitBounds: (ne: [number, number], sw: [number, number], pad?: number | number[], duration?: number) => { + if (!map) return; - const paddingObj = Array.isArray(pad) ? { top: pad[0] ?? 60, right: pad[1] ?? 60, bottom: pad[2] ?? 60, left: pad[3] ?? 60 } : { top: pad ?? 60, right: pad ?? 60, bottom: pad ?? 60, left: pad ?? 60 }; + const paddingObj = Array.isArray(pad) ? { top: pad[0] ?? 60, right: pad[1] ?? 60, bottom: pad[2] ?? 60, left: pad[3] ?? 60 } : { top: pad ?? 60, right: pad ?? 60, bottom: pad ?? 60, left: pad ?? 60 }; + + try { + map.fitBounds( + [ + [sw[0], sw[1]], + [ne[0], ne[1]], + ], + { padding: paddingObj, duration: duration || 1000 }, + { _programmatic: true } + ); + } catch { + // ignore projection errors + } + }, + })); + + // Handle bounds prop (declarative camera fitting) + useEffect(() => { + if (!map || !bounds) return; + + const pad = padding ? { top: padding.paddingTop ?? 40, right: padding.paddingRight ?? 40, bottom: padding.paddingBottom ?? 40, left: padding.paddingLeft ?? 40 } : { top: 40, right: 40, bottom: 40, left: 40 }; try { map.fitBounds( [ - [sw[0], sw[1]], - [ne[0], ne[1]], + [bounds.sw[0], bounds.sw[1]], + [bounds.ne[0], bounds.ne[1]], ], - { padding: paddingObj, duration: duration || 1000 }, + { padding: pad, duration: animationDuration ?? 0 }, { _programmatic: true } ); } catch { // ignore projection errors } - }, - })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, bounds, padding]); - // Handle bounds prop (declarative camera fitting) - useEffect(() => { - if (!map || !bounds) return; + // Honor defaultSettings for the very first placement, matching the native + // Camera. Without this the web map booted at the department center / zoom 4 + // and only then animated to the unit. + useEffect(() => { + if (!map || hasInitialized.current || !defaultSettings) return; - const pad = padding ? { top: padding.paddingTop ?? 40, right: padding.paddingRight ?? 40, bottom: padding.paddingBottom ?? 40, left: padding.paddingLeft ?? 40 } : { top: 40, right: 40, bottom: 40, left: 40 }; + const center = defaultSettings.centerCoordinate; + if (!center || center.length !== 2 || !isFinite(center[0]) || !isFinite(center[1])) return; - try { - map.fitBounds( - [ - [bounds.sw[0], bounds.sw[1]], - [bounds.ne[0], bounds.ne[1]], - ], - { padding: pad, duration: animationDuration ?? 0 }, - { _programmatic: true } - ); - } catch { - // ignore projection errors - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [map, bounds, padding]); + hasInitialized.current = true; + try { + map.jumpTo({ center: center as [number, number], zoom: defaultSettings.zoomLevel, bearing: defaultSettings.heading, pitch: defaultSettings.pitch }, { _programmatic: true }); + } catch { + // ignore projection errors during initialization + } + // Initial placement only — later moves go through setCamera/centerCoordinate. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); - // Handle centerCoordinate / zoomLevel changes - useEffect(() => { - if (!map) return; + // Handle centerCoordinate / zoomLevel changes + useEffect(() => { + if (!map) return; - if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) { - if (!hasInitialized.current) { - hasInitialized.current = true; - try { - map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true }); - } catch { - // ignore projection errors during initialization + if (centerCoordinate && centerCoordinate.length === 2 && isFinite(centerCoordinate[0]) && isFinite(centerCoordinate[1])) { + if (!hasInitialized.current) { + hasInitialized.current = true; + try { + map.jumpTo({ center: centerCoordinate as [number, number], zoom: zoomLevel, bearing: heading, pitch: pitch }, { _programmatic: true }); + } catch { + // ignore projection errors during initialization + } + return; } - return; - } - const cameraOptions = { - center: centerCoordinate as [number, number], - zoom: zoomLevel, - bearing: heading, - pitch: pitch, - duration: animationDuration, - }; + 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 }); + try { + if (animationMode === 'flyTo') { + map.flyTo(cameraOptions, { _programmatic: true }); + } else { + map.easeTo(cameraOptions, { _programmatic: true }); + } + } catch { + // Suppress projection-matrix errors during resize/transition } - } catch { - // Suppress projection-matrix errors during resize/transition } - } - }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]); + }, [map, centerCoordinate, zoomLevel, heading, pitch, animationDuration, animationMode]); - // Handle followUserLocation - useEffect(() => { - if (!map || !followUserLocation) return; + // Handle followUserLocation + useEffect(() => { + if (!map || !followUserLocation) return; - let triggerTimeoutId: any; + let triggerTimeoutId: any; - if (!geolocateControl.current) { - geolocateControl.current = new mapboxgl.GeolocateControl({ - positionOptions: { enableHighAccuracy: true }, - trackUserLocation: true, - showUserHeading: true, - }); - map.addControl(geolocateControl.current); - } + if (!geolocateControl.current) { + geolocateControl.current = new mapboxgl.GeolocateControl({ + positionOptions: { enableHighAccuracy: true }, + trackUserLocation: true, + showUserHeading: true, + }); + map.addControl(geolocateControl.current); + } - triggerTimeoutId = setTimeout(() => { - geolocateControl.current?.trigger(); - }, 100); + triggerTimeoutId = setTimeout(() => { + geolocateControl.current?.trigger(); + }, 100); - return () => { - if (triggerTimeoutId) clearTimeout(triggerTimeoutId); - if (geolocateControl.current) { - try { - map.removeControl(geolocateControl.current); - } catch { - // map may already be destroyed during route transitions + return () => { + if (triggerTimeoutId) clearTimeout(triggerTimeoutId); + if (geolocateControl.current) { + try { + map.removeControl(geolocateControl.current); + } catch { + // map may already be destroyed during route transitions + } + geolocateControl.current = null; } - geolocateControl.current = null; - } - }; - }, [map, followUserLocation, followZoomLevel]); + }; + }, [map, followUserLocation, followZoomLevel]); - return null; -}); + return null; + } +); Camera.displayName = 'Camera'; @@ -617,6 +708,23 @@ interface PointAnnotationProps { onSelected?: () => void; } +/** + * Native anchors are fractional {x, y} offsets into the marker box; mapbox-gl + * takes a named anchor instead. Measuring the element to convert (the previous + * approach) always read 0×0 because the React root has not rendered yet when + * the marker is created, so every pin ended up centered on its coordinate. + */ +function toMarkerAnchor(anchor: string | { x: number; y: number } | undefined): string { + if (typeof anchor === 'string') return anchor; + if (!anchor || typeof anchor.x !== 'number' || typeof anchor.y !== 'number') return 'center'; + + const vertical = anchor.y >= 0.75 ? 'bottom' : anchor.y <= 0.25 ? 'top' : ''; + const horizontal = anchor.x >= 0.75 ? 'right' : anchor.x <= 0.25 ? 'left' : ''; + + if (vertical && horizontal) return `${vertical}-${horizontal}`; + return vertical || horizontal || 'center'; +} + // PointAnnotation component export const PointAnnotation: React.FC = ({ id, coordinate, title, children, anchor = { x: 0.5, y: 0.5 }, onSelected }) => { const map = useContext(MapContext); @@ -635,21 +743,10 @@ export const PointAnnotation: React.FC = ({ id, coordinate const root = createRoot(container); containerRootRef.current = root; - const markerOptions: any = { element: container }; - - if (typeof anchor === 'string') { - markerOptions.anchor = anchor as any; - } + const markerOptions: any = { element: container, anchor: toMarkerAnchor(anchor) }; markerRef.current = new mapboxgl.Marker(markerOptions).setLngLat(coordinate).addTo(map); - if (typeof anchor === 'object' && anchor !== null && 'x' in anchor && 'y' in anchor) { - const rect = container.getBoundingClientRect(); - const xOffset = (anchor.x - 0.5) * rect.width; - const yOffset = (anchor.y - 0.5) * rect.height; - markerRef.current.setOffset([xOffset, yOffset]); - } - if (title) { markerRef.current.setPopup(new mapboxgl.Popup().setText(title)); } @@ -741,9 +838,12 @@ export const UserLocation: React.FC<{ visible?: boolean; showsUserHeadingIndicat }; // MarkerView component -export const MarkerView: React.FC<{ coordinate: [number, number]; children?: React.ReactNode }> = ({ coordinate, children }) => { +// A stable `id` keeps the underlying mapbox-gl Marker alive across coordinate +// updates; without one the id is derived from the coordinate, so every location +// change tears down and recreates the DOM marker. +export const MarkerView: React.FC<{ id?: string; coordinate: [number, number]; children?: React.ReactNode; anchor?: { x: number; y: number }; allowOverlap?: boolean }> = ({ id, coordinate, children, anchor }) => { return ( - + {children} ); @@ -764,7 +864,15 @@ interface ShapeSourceProps { */ export const ShapeSource: React.FC = ({ id, shape, children, onPress }) => { const map = useContext(MapContext); - const [sourceReady, setSourceReady] = useState(false); + const styleGeneration = useContext(StyleGenerationContext); + // Tracked as a generation rather than a boolean so a style swap forces the + // source back through the "not ready" phase: child layers then unregister and + // only re-add once the source exists again in the new style document. + const [readyGeneration, setReadyGeneration] = useState(-1); + const sourceReady = readyGeneration === styleGeneration; + // Guards the deferred removal below against tearing down a source that a + // newer add-effect (e.g. after a style swap) has already re-registered. + const addTokenRef = useRef(0); // Use a ref so the click handler always sees the latest onPress without re-registering const onPressRef = useRef(onPress); onPressRef.current = onPress; @@ -774,6 +882,7 @@ export const ShapeSource: React.FC = ({ id, shape, children, o if (!map) return; const data: GeoJSON.GeoJSON = shape || { type: 'FeatureCollection', features: [] }; + const token = ++addTokenRef.current; try { if (map.getSource(id)) { @@ -781,17 +890,22 @@ export const ShapeSource: React.FC = ({ id, shape, children, o } else { map.addSource(id, { type: 'geojson', data }); } - setSourceReady(true); + setReadyGeneration(styleGeneration); } catch (e) { console.warn('[ShapeSource] Failed to add source:', id, e); } return () => { - setSourceReady(false); + setReadyGeneration(-1); // Defer source removal so child layer cleanups run first - setTimeout(() => safeRemoveSource(map, id), 0); + setTimeout(() => { + // Reading the CURRENT token is the point: if a newer add-effect has + // claimed this source id (e.g. after a style swap) we must not remove it. + // eslint-disable-next-line react-hooks/exhaustive-deps + if (addTokenRef.current === token) safeRemoveSource(map, id); + }, 0); }; - }, [map, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps // Update source data when shape changes (without removing/re-adding source) useEffect(() => { @@ -845,27 +959,37 @@ interface ImageSourceProps { */ export const ImageSource: React.FC = ({ id, url, coordinates, children }) => { const map = useContext(MapContext); - const [sourceReady, setSourceReady] = useState(false); + const styleGeneration = useContext(StyleGenerationContext); + const [readyGeneration, setReadyGeneration] = useState(-1); + const sourceReady = readyGeneration === styleGeneration; + const addTokenRef = useRef(0); useEffect(() => { if (!map || !url) return; + const token = ++addTokenRef.current; + try { if (map.getSource(id)) { (map.getSource(id) as any).updateImage({ url, coordinates }); } else { map.addSource(id, { type: 'image', url, coordinates }); } - setSourceReady(true); + setReadyGeneration(styleGeneration); } catch (e) { console.warn('[ImageSource] Failed to add source:', id, e); } return () => { - setSourceReady(false); - setTimeout(() => safeRemoveSource(map, id), 0); + setReadyGeneration(-1); + setTimeout(() => { + // Reading the CURRENT token is the point: if a newer add-effect has + // claimed this source id (e.g. after a style swap) we must not remove it. + // eslint-disable-next-line react-hooks/exhaustive-deps + if (addTokenRef.current === token) safeRemoveSource(map, id); + }, 0); }; - }, [map, id, url]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, id, url, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps // Update coordinates if they change useEffect(() => { @@ -892,25 +1016,35 @@ interface RasterSourceProps { */ export const RasterSource: React.FC = ({ id, tileUrlTemplates, tileSize = 256, children }) => { const map = useContext(MapContext); - const [sourceReady, setSourceReady] = useState(false); + const styleGeneration = useContext(StyleGenerationContext); + const [readyGeneration, setReadyGeneration] = useState(-1); + const sourceReady = readyGeneration === styleGeneration; + const addTokenRef = useRef(0); useEffect(() => { if (!map || !tileUrlTemplates?.length) return; + const token = ++addTokenRef.current; + try { if (!map.getSource(id)) { map.addSource(id, { type: 'raster', tiles: tileUrlTemplates, tileSize }); } - setSourceReady(true); + setReadyGeneration(styleGeneration); } catch (e) { console.warn('[RasterSource] Failed to add source:', id, e); } return () => { - setSourceReady(false); - setTimeout(() => safeRemoveSource(map, id), 0); + setReadyGeneration(-1); + setTimeout(() => { + // Reading the CURRENT token is the point: if a newer add-effect has + // claimed this source id (e.g. after a style swap) we must not remove it. + // eslint-disable-next-line react-hooks/exhaustive-deps + if (addTokenRef.current === token) safeRemoveSource(map, id); + }, 0); }; - }, [map, id, tileUrlTemplates, tileSize]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, id, tileUrlTemplates, tileSize, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps return {children}; }; @@ -927,6 +1061,7 @@ interface LayerProps { */ export const LineLayer: React.FC = ({ id, style }) => { const map = useContext(MapContext); + const styleGeneration = useContext(StyleGenerationContext); const sourceId = useContext(SourceContext); // null until source is ready useEffect(() => { @@ -941,7 +1076,7 @@ export const LineLayer: React.FC = ({ id, style }) => { } return () => safeRemoveLayer(map, id); - }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps // Update paint when style changes useEffect(() => { @@ -964,6 +1099,7 @@ export const LineLayer: React.FC = ({ id, style }) => { */ export const FillLayer: React.FC = ({ id, style }) => { const map = useContext(MapContext); + const styleGeneration = useContext(StyleGenerationContext); const sourceId = useContext(SourceContext); useEffect(() => { @@ -978,7 +1114,7 @@ export const FillLayer: React.FC = ({ id, style }) => { } return () => safeRemoveLayer(map, id); - }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!map || !sourceId || !map.getLayer(id)) return; @@ -998,6 +1134,7 @@ export const FillLayer: React.FC = ({ id, style }) => { */ export const CircleLayer: React.FC = ({ id, style }) => { const map = useContext(MapContext); + const styleGeneration = useContext(StyleGenerationContext); const sourceId = useContext(SourceContext); useEffect(() => { @@ -1012,7 +1149,7 @@ export const CircleLayer: React.FC = ({ id, style }) => { } return () => safeRemoveLayer(map, id); - }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!map || !sourceId || !map.getLayer(id)) return; @@ -1032,6 +1169,7 @@ export const CircleLayer: React.FC = ({ id, style }) => { */ export const SymbolLayer: React.FC = ({ id, style }) => { const map = useContext(MapContext); + const styleGeneration = useContext(StyleGenerationContext); const sourceId = useContext(SourceContext); useEffect(() => { @@ -1046,7 +1184,7 @@ export const SymbolLayer: React.FC = ({ id, style }) => { } return () => safeRemoveLayer(map, id); - }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!map || !sourceId || !map.getLayer(id)) return; @@ -1068,6 +1206,7 @@ export const SymbolLayer: React.FC = ({ id, style }) => { */ export const RasterLayer: React.FC = ({ id, style }) => { const map = useContext(MapContext); + const styleGeneration = useContext(StyleGenerationContext); const sourceId = useContext(SourceContext); useEffect(() => { @@ -1082,7 +1221,7 @@ export const RasterLayer: React.FC = ({ id, style }) => { } return () => safeRemoveLayer(map, id); - }, [map, sourceId, id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [map, sourceId, id, styleGeneration]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!map || !sourceId || !map.getLayer(id)) return; @@ -1097,8 +1236,71 @@ export const RasterLayer: React.FC = ({ id, style }) => { return null; }; -// Passthrough / no-op components for API compatibility -export const Images: React.FC = () => null; +/** + * Images — registers named images with the map style so SymbolLayers can + * reference them via iconImage. Values may be URI strings (including data: + * URIs) or {uri} objects; entries that don't resolve to a string are skipped. + */ +export const Images: React.FC<{ images?: Record; children?: React.ReactNode }> = ({ images }) => { + const map = useContext(MapContext); + // setStyle() drops custom images along with everything else, so re-register + // them whenever the style document is replaced. + const styleGeneration = useContext(StyleGenerationContext); + + useEffect(() => { + if (!map || !images) return; + + const added: string[] = []; + // Image decoding is async, so onload can land after this effect is cleaned + // up. Without this flag the late add re-registered an image that the + // cleanup had already passed over, leaking it into the style forever. + let cancelled = false; + + Object.entries(images).forEach(([name, source]) => { + const uri = typeof source === 'string' ? source : typeof source?.uri === 'string' ? source.uri : undefined; + if (!uri) return; + + try { + if (map.hasImage(name)) return; + } catch { + return; + } + + const img = new window.Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + if (map.__removed) return; + if (cancelled) { + // Effect already torn down — drop anything that slipped in. + if (map.hasImage(name)) map.removeImage(name); + return; + } + if (!map.hasImage(name)) { + map.addImage(name, img); + added.push(name); + } + } catch { + /* map may already be destroyed */ + } + }; + img.src = uri; + }); + + return () => { + cancelled = true; + added.forEach((name) => { + try { + if (!map.__removed && map.hasImage(name)) map.removeImage(name); + } catch { + /* ignore */ + } + }); + }; + }, [map, images, styleGeneration]); + + return null; +}; export const Callout: React.FC = ({ children }) => <>{children}; // Default export matching native structure diff --git a/src/components/maps/pin-marker.tsx b/src/components/maps/pin-marker.tsx index a8ec6af7..4e712815 100644 --- a/src/components/maps/pin-marker.tsx +++ b/src/components/maps/pin-marker.tsx @@ -1,6 +1,6 @@ import { useColorScheme } from 'nativewind'; import React from 'react'; -import { Image, StyleSheet, Text, TouchableOpacity } from 'react-native'; +import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import type { PointAnnotation } from '@/components/maps/mapbox'; import { MAP_ICONS } from '@/constants/map-icons'; @@ -13,10 +13,12 @@ interface PinMarkerProps { title: string; size?: number; markerRef?: React.ComponentRef | null; + /** Highlights the marker with a ring — used for the department's active call. */ + isActive?: boolean; onPress?: () => void; } -const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, onPress }) => { +const PinMarker: React.FC = React.memo(({ imagePath, poiImage, title, size = 32, isActive = false, onPress }) => { const { colorScheme } = useColorScheme(); // Prefer poiImage (new field) over imagePath (null for POIs after backend fix), @@ -26,10 +28,19 @@ const PinMarker: React.FC = React.memo(({ imagePath, poiImage, t // Unknown markers fall back to a neutral pin, not the call icon -- that one is a flame. const icon = iconKey && MAP_ICONS[iconKey] ? MAP_ICONS[iconKey] : MAP_ICONS['flag']; + const ringSize = size + 16; + return ( - - + {isActive ? ( + + + + + ) : ( + + )} + {title} @@ -43,6 +54,16 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + iconWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + activeRing: { + position: 'absolute', + backgroundColor: 'rgba(239, 68, 68, 0.2)', + borderWidth: 3, + borderColor: '#ef4444', + }, image: { overflow: 'visible', resizeMode: 'cover', @@ -54,6 +75,11 @@ const styles = StyleSheet.create({ fontWeight: '600', textAlign: 'center', }, + // The active call label keeps a fixed accent color (readable on both themes) + // so the highlighted pin stands out from the rest. + titleActive: { + color: '#ef4444', + }, }); export default PinMarker; diff --git a/src/components/maps/unit-location-marker.tsx b/src/components/maps/unit-location-marker.tsx new file mode 100644 index 00000000..2d9ff799 --- /dev/null +++ b/src/components/maps/unit-location-marker.tsx @@ -0,0 +1,114 @@ +import React, { useMemo } from 'react'; + +import Mapbox from '@/components/maps/mapbox'; +import { createCirclePolygon, normalizeHeading } from '@/lib/map-camera'; +import { isWeb } from '@/lib/platform'; + +/** + * The unit's own location indicator, rendered as native map layers instead of a + * view-based annotation. PointAnnotation children are rasterized to a snapshot + * on iOS (and often render blank until refresh()), which is why the previous + * view-based marker was invisible; GL layers always draw, rotate with the map, + * and keep a correct screen size at every zoom. + * + * Bottom to top: GPS-accuracy circle (meters, geographic), heading arrow + * (map-aligned so it points at the true ground direction even when the camera + * is rotated), location dot. + */ + +interface UnitLocationMarkerProps { + latitude: number; + longitude: number; + /** Raw GPS heading — may be null or -1 when there is no fix. */ + heading: number | null; + /** GPS fix accuracy radius in meters, or null when unknown. */ + accuracy: number | null; +} + +const LOCATION_BLUE = '#3b82f6'; + +// Native resolves the bundled asset; mapbox-gl on web can't consume Metro asset +// ids, so web registers the same 64x56 chevron as a data URI. +const ARROW_IMAGE_NATIVE = require('@assets/mapping/direction_arrow.png'); +const ARROW_IMAGE_WEB = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAA4CAYAAABNGP5yAAACL0lEQVR42u2ZPU7DQBCFfYMcIUfIAShScgAO4AtwAAokS3RUCxINBQUViihSgpAQUEGFK4REgTuQaCJR5D8smcgLo8Qm61k77G7mSU+y4rWlN57xfoqD4J8kpRTyVyJYJ00DN+SiGutUgDijAPG6hI9U4rvn8cxIke/h61N3IOln70tu7nfl1mFvdpwKztV9LsCNSnpwOZAbe92Z4RjpxtfwoUr4mIx/wivDb0ihb+FrqvVB0PbzBQiPe7gAsLbmUwHaKtnJ7XAhvDKcQ2r7Er6pEr28T3LDK791JrgITR9aP1Fptk/7SwsAa5ASp0cB427rYbQ0vDKsdR6TMe5CW8Oer1sAWIvYwE1Mxri70+prh1eGa5zF5HncLRpe2UlMzsJdagGcxOQ83KXaKUxehrtUO4HJOrhLtROYrIu7VFuNyUVxl2orMZmCu1RbiclU3KXaKkw2wV2qrcJkU9yl2gpMLgt3ncTkMnHXSUwuA3d3zwed66exOLsfCTh2BpNNcBeemrgYJK8fkxBvYXA8LUYI54p200oxmYq7sH8fXQ3bOvACa2At4K91mFwUd1WbU+YTrtEdj5Vgsi7u5rW5SdfpjEelmKyDu0Xa3OQh5I1HpZj8F+6atLnJNpw1HpVgchbult3mZY1HJZiMcRcqXHWbm47H3AsxNr1phLYX4cKfkimlCrRdRyY3ilPwce7zVPriDtMMddINAk/k1ad2FovFYrFYLBaLxWKxWCxTfQPDAf6eowsOPgAAAABJRU5ErkJggg=='; + +const UnitLocationMarker: React.FC = ({ latitude, longitude, heading, accuracy }) => { + const safeHeading = normalizeHeading(heading); + + const locationPoint = useMemo( + (): GeoJSON.Feature => ({ + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates: [longitude, latitude] }, + }), + [longitude, latitude] + ); + + const accuracyPolygon = useMemo(() => { + if (accuracy == null || !Number.isFinite(accuracy) || accuracy <= 0) return null; + return createCirclePolygon(longitude, latitude, accuracy); + }, [longitude, latitude, accuracy]); + + const arrowImages = useMemo(() => ({ 'unit-heading-arrow': isWeb ? ARROW_IMAGE_WEB : ARROW_IMAGE_NATIVE }), []); + + return ( + <> + {accuracyPolygon ? ( + + + + + ) : null} + + + {safeHeading != null ? ( + + + + ) : null} + + + + + ); +}; + +export default React.memo(UnitLocationMarker); diff --git a/src/components/notifications/NotificationButton.tsx b/src/components/notifications/NotificationButton.tsx index 34293665..605dd4bc 100644 --- a/src/components/notifications/NotificationButton.tsx +++ b/src/components/notifications/NotificationButton.tsx @@ -1,6 +1,7 @@ import { useCounts } from '@novu/react-native'; import { BellIcon } from 'lucide-react-native'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Pressable, View } from '@/components/ui'; import { Text } from '@/components/ui/text'; @@ -9,6 +10,7 @@ interface NotificationButtonProps { } export const NotificationButton = ({ onPress }: NotificationButtonProps) => { + const { t } = useTranslation(); const { counts, isLoading } = useCounts({ filters: [ { @@ -20,7 +22,7 @@ export const NotificationButton = ({ onPress }: NotificationButtonProps) => { if (isLoading) return ; return ( - + diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx index c0c1d9ef..e8a42f7d 100644 --- a/src/components/notifications/NotificationInbox.tsx +++ b/src/components/notifications/NotificationInbox.tsx @@ -1,8 +1,10 @@ import { useNotifications } from '@novu/react-native'; import { router } from 'expo-router'; import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from 'lucide-react-native'; +import { useColorScheme } from 'nativewind'; import React, { useEffect, useRef, useState } from 'react'; -import { ActivityIndicator, Animated, Appearance, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { ActivityIndicator, Animated, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, useWindowDimensions, View } from 'react-native'; import { deleteMessage } from '@/api/novu/inbox'; import { NotificationDetail } from '@/components/notifications/NotificationDetail'; @@ -16,10 +18,43 @@ import { useToastStore } from '@/stores/toast/store'; import { type NotificationPayload } from '@/types/notification'; // Constants -const { width } = Dimensions.get('window'); -const SIDEBAR_WIDTH = Math.min(width * 0.85, 400); const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0; +const getSidebarWidth = (windowWidth: number) => Math.min(windowWidth * 0.85, 400); + +/** Color-dependent style fragments; computed per render from the reactive color scheme. */ +const getThemedStyles = (isDark: boolean) => + ({ + sidebarContainer: { + backgroundColor: isDark ? '#171717' : '#fff', + shadowColor: isDark ? '#262626' : '#e5e5e5', + }, + selectionCount: { + color: isDark ? '#ffffff' : '#000000', + }, + notificationItem: { + borderBottomColor: isDark ? '#333333' : '#eee', + }, + unreadNotificationItem: { + backgroundColor: isDark ? '#262626' : '#f0f7ff', + }, + selectedNotificationItem: { + backgroundColor: isDark ? '#1e3a8a' : '#dbeafe', + }, + unreadIndicator: { + backgroundColor: isDark ? '#60a5fa' : '#3b82f6', + }, + notificationBody: { + color: isDark ? '#e5e5e5' : '#333333', + }, + unreadNotificationText: { + color: isDark ? '#ffffff' : '#000000', + }, + timestamp: { + color: isDark ? '#a3a3a3' : '#666', + }, + }) as const; + interface NotificationInboxProps { isOpen: boolean; onClose: () => void; @@ -37,6 +72,8 @@ interface NotificationRowProps { const NotificationRow = React.memo( ({ notification, unread, isSelectionMode, isSelected, onPress, onLongPress, onNavigateToReference }: NotificationRowProps) => { + const { colorScheme } = useColorScheme(); + const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]); const handlePress = React.useCallback(() => onPress(notification), [onPress, notification]); const handleLongPress = React.useCallback(() => onLongPress(notification), [onLongPress, notification]); const handleNavigate = React.useCallback( @@ -45,8 +82,12 @@ const NotificationRow = React.memo( ); return ( - - {unread ? : null} + + {unread ? : null} {isSelectionMode ? ( @@ -55,8 +96,8 @@ const NotificationRow = React.memo( ) : null} - {notification.title} - + {notification.title} + {new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()} @@ -89,6 +130,11 @@ const NotificationRow = React.memo( NotificationRow.displayName = 'NotificationRow'; export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => { + const { t } = useTranslation(); + const { colorScheme } = useColorScheme(); + const { width: windowWidth } = useWindowDimensions(); + const sidebarWidth = getSidebarWidth(windowWidth); + const themed = React.useMemo(() => getThemedStyles(colorScheme === 'dark'), [colorScheme]); const activeUnitId = useCoreStore((state) => state.activeUnitId); const config = useCoreStore((state: any) => state.config); const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications(); @@ -100,7 +146,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = const [isDeletingSelected, setIsDeletingSelected] = useState(false); // Animation values - const slideAnim = useRef(new Animated.Value(SIDEBAR_WIDTH)).current; + const slideAnim = useRef(new Animated.Value(sidebarWidth)).current; const fadeAnim = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -122,7 +168,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = // Animate out and reset state Animated.parallel([ Animated.timing(slideAnim, { - toValue: SIDEBAR_WIDTH, + toValue: sidebarWidth, duration: 300, useNativeDriver: true, }), @@ -139,7 +185,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = setSelectedNotification(null); setShowDeleteConfirmModal(false); } - }, [isOpen, slideAnim, fadeAnim]); + }, [isOpen, slideAnim, fadeAnim, sidebarWidth]); const toggleNotificationSelection = React.useCallback((notificationId: string) => { setSelectedNotificationIds((prev) => { @@ -210,27 +256,27 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = const deletePromises = Array.from(selectedNotificationIds).map((id) => deleteMessage(id)); await Promise.all(deletePromises); - showToast('success', `${selectedNotificationIds.size} notification${selectedNotificationIds.size > 1 ? 's' : ''} removed`); + showToast('success', selectedNotificationIds.size > 1 ? t('notifications.removed_count', { count: selectedNotificationIds.size }) : t('notifications.removed_one')); exitSelectionMode(); refetch(); } catch (error) { - showToast('error', 'Failed to remove notifications'); + showToast('error', t('notifications.remove_failed_count')); } finally { setIsDeletingSelected(false); } - }, [selectedNotificationIds, showToast, exitSelectionMode, refetch]); + }, [selectedNotificationIds, showToast, exitSelectionMode, refetch, t]); const handleDeleteNotification = React.useCallback( async (_id: string) => { try { await deleteMessage(_id); - showToast('success', 'Notification removed'); + showToast('success', t('notifications.removed_one')); refetch(); } catch (error) { - showToast('error', 'Failed to remove notification'); + showToast('error', t('notifications.remove_failed_one')); } }, - [showToast, refetch] + [showToast, refetch, t] ); const handleNavigateToReference = React.useCallback( @@ -296,7 +342,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = const renderEmpty = () => ( - No updates available + {t('notifications.empty')} ); @@ -317,7 +363,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = {/* Sidebar container */} - + {selectedNotification ? ( setSelectedNotification(null)} onDelete={handleDeleteNotification} onNavigateToReference={handleNavigateToReference} /> @@ -327,28 +373,28 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = {isSelectionMode ? ( <> - {selectedNotificationIds.size} selected + {t('notifications.selected_count', { count: selectedNotificationIds.size })} - ) : ( <> - Notifications + {t('notifications.title')} - + - + @@ -362,7 +408,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) = ) : !activeUnitId || !config ? ( - Unable to load notifications + {t('notifications.unable_to_load')} ) : ( - Confirm Delete + {t('notifications.confirm_delete_title')} - - Are you sure you want to delete {selectedNotificationIds.size} notification{selectedNotificationIds.size > 1 ? 's' : ''}? This action cannot be undone. - + {selectedNotificationIds.size > 1 ? t('notifications.confirm_delete_message_count', { count: selectedNotificationIds.size }) : t('notifications.confirm_delete_message_one')} @@ -421,10 +465,7 @@ const styles = StyleSheet.create({ position: 'absolute', top: 0, right: 0, - width: SIDEBAR_WIDTH, height: '100%', - backgroundColor: Appearance.getColorScheme() === 'dark' ? '#171717' : '#fff', - shadowColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#e5e5e5', shadowOffset: { width: -2, height: 0, @@ -470,7 +511,6 @@ const styles = StyleSheet.create({ selectionCount: { fontSize: 16, fontWeight: '600', - color: Appearance.getColorScheme() === 'dark' ? '#ffffff' : '#000000', }, selectionActions: { flexDirection: 'row', @@ -481,22 +521,14 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 16, borderBottomWidth: 1, - borderBottomColor: Appearance.getColorScheme() === 'dark' ? '#333333' : '#eee', position: 'relative', }, - unreadNotificationItem: { - backgroundColor: Appearance.getColorScheme() === 'dark' ? '#262626' : '#f0f7ff', - }, - selectedNotificationItem: { - backgroundColor: Appearance.getColorScheme() === 'dark' ? '#1e3a8a' : '#dbeafe', - }, unreadIndicator: { position: 'absolute', left: 0, top: 0, width: 4, height: '100%', - backgroundColor: Appearance.getColorScheme() === 'dark' ? '#60a5fa' : '#3b82f6', }, selectionIndicator: { marginRight: 12, @@ -508,15 +540,12 @@ const styles = StyleSheet.create({ notificationBody: { fontSize: 16, marginBottom: 4, - color: Appearance.getColorScheme() === 'dark' ? '#e5e5e5' : '#333333', }, unreadNotificationText: { fontWeight: '600', - color: Appearance.getColorScheme() === 'dark' ? '#ffffff' : '#000000', }, timestamp: { fontSize: 12, - color: Appearance.getColorScheme() === 'dark' ? '#a3a3a3' : '#666', }, actionButtons: { flexDirection: 'row', diff --git a/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx b/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx new file mode 100644 index 00000000..94389619 --- /dev/null +++ b/src/components/notifications/__tests__/NotificationInbox.i18n-theme.test.tsx @@ -0,0 +1,202 @@ +/** + * Renders the real NotificationInbox (the sibling suite substitutes a hand-written + * stand-in, so it never exercised this file). Covers the two regressions fixed here: + * hardcoded English copy, and a theme frozen at module-evaluation time because the + * colors were baked into a module-scope StyleSheet via Appearance.getColorScheme(). + */ +import { render, screen } from '@testing-library/react-native'; +import React from 'react'; + +let mockColorScheme = 'light'; + +// Only useColorScheme is overridden; gluestack needs the real `styled` from this module. +jest.mock('nativewind', () => ({ + ...jest.requireActual('nativewind'), + useColorScheme: () => ({ colorScheme: mockColorScheme }), +})); + +jest.mock('@novu/react-native', () => ({ + useNotifications: jest.fn(), +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: jest.fn(), +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: jest.fn(), +})); + +jest.mock('@/api/novu/inbox', () => ({ + deleteMessage: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/components/notifications/NotificationDetail', () => ({ + NotificationDetail: () => null, +})); + +// Interpolated values are surfaced so assertions can prove the count reaches t(). +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => (options && 'count' in options ? `${key}:${options.count}` : key), + }), +})); + +jest.mock('lucide-react-native', () => { + const React = require('react'); + const { View } = require('react-native'); + const icon = React.forwardRef((props: Record, ref: unknown) => React.createElement(View, { ...props, ref })); + return new Proxy({}, { get: () => icon }); +}); + +import { useNotifications } from '@novu/react-native'; + +import { useCoreStore } from '@/stores/app/core-store'; +import { useToastStore } from '@/stores/toast/store'; + +import { NotificationInbox } from '../NotificationInbox'; + +const mockUseNotifications = useNotifications as unknown as jest.Mock; +const mockUseCoreStore = useCoreStore as unknown as jest.Mock; +const mockUseToastStore = useToastStore as unknown as jest.Mock; + +const notifications = [ + { + id: '1', + subject: 'Structure fire dispatched', + body: 'Engine 6 responding', + createdAt: '2024-01-01T10:00:00Z', + isRead: false, + type: 'info', + payload: {}, + }, +]; + +/** Flattens the RN style prop (arrays/nested arrays) into one object. */ +const flattenStyle = (style: unknown): Record => { + if (Array.isArray(style)) return style.reduce>((acc, entry) => ({ ...acc, ...flattenStyle(entry) }), {}); + return (style ?? {}) as Record; +}; + +/** Every backgroundColor present anywhere in the rendered tree. */ +const collectBackgroundColors = (node: unknown): string[] => { + if (!node || typeof node !== 'object') return []; + const element = node as { props?: { style?: unknown }; children?: unknown[] }; + const own = flattenStyle(element.props?.style).backgroundColor; + const fromChildren = (element.children ?? []).flatMap(collectBackgroundColors); + return typeof own === 'string' ? [own, ...fromChildren] : fromChildren; +}; + +describe('NotificationInbox', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockColorScheme = 'light'; + + mockUseNotifications.mockReturnValue({ + notifications, + isLoading: false, + fetchMore: jest.fn(), + hasMore: false, + refetch: jest.fn(), + }); + + mockUseCoreStore.mockImplementation((selector: (state: unknown) => unknown) => + selector({ + activeUnitId: 'unit-1', + config: { + NovuApplicationId: 'app-id', + NovuBackendApiUrl: 'backend-url', + NovuSocketUrl: 'socket-url', + }, + }) + ); + + mockUseToastStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ showToast: jest.fn() })); + }); + + it('renders header and controls through t() rather than hardcoded English', () => { + const { unmount } = render(); + + expect(screen.getByText('notifications.title')).toBeTruthy(); + expect(screen.queryByText('Notifications')).toBeNull(); + + unmount(); + }); + + it('labels the icon-only header controls for screen readers', () => { + const { unmount } = render(); + + expect(screen.getByLabelText('notifications.enter_selection_mode')).toBeTruthy(); + expect(screen.getByLabelText('common.close')).toBeTruthy(); + + unmount(); + }); + + it('renders the translated empty state when there are no notifications', () => { + mockUseNotifications.mockReturnValue({ + notifications: [], + isLoading: false, + fetchMore: jest.fn(), + hasMore: false, + refetch: jest.fn(), + }); + + const { unmount } = render(); + + expect(screen.getByText('notifications.empty')).toBeTruthy(); + expect(screen.queryByText('No updates available')).toBeNull(); + + unmount(); + }); + + it('repaints the sidebar when the color scheme changes', () => { + const { toJSON, unmount } = render(); + + expect(collectBackgroundColors(toJSON())).toContain('#fff'); + + // A scheme flip must repaint without an app restart — the previous module-scope + // Appearance.getColorScheme() baked these colors in at import time, so the sidebar + // kept the scheme that was active when the bundle first loaded. + mockColorScheme = 'dark'; + screen.rerender(); + + const darkBackgrounds = collectBackgroundColors(toJSON()); + expect(darkBackgrounds).toContain('#171717'); + expect(darkBackgrounds).not.toContain('#fff'); + + unmount(); + }); + + it('sizes the sidebar from the current window width rather than a frozen Dimensions read', () => { + const { toJSON, unmount } = render(); + + // jest-expo reports a 750pt-wide window; 85% of that is below the 400 cap. + const widths: unknown[] = []; + const walk = (node: unknown) => { + if (!node || typeof node !== 'object') return; + const element = node as { props?: { style?: unknown }; children?: unknown[] }; + const width = flattenStyle(element.props?.style).width; + if (typeof width === 'number') widths.push(width); + (element.children ?? []).forEach(walk); + }; + walk(toJSON()); + + expect(widths).toContain(400); + + unmount(); + }); + + it('renders nothing when Novu config is incomplete', () => { + mockUseCoreStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ activeUnitId: null, config: null })); + + const { toJSON, unmount } = render(); + + expect(toJSON()).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/routes/active-routes-list.tsx b/src/components/routes/active-routes-list.tsx index 06d73e80..ebdcfcba 100644 --- a/src/components/routes/active-routes-list.tsx +++ b/src/components/routes/active-routes-list.tsx @@ -1,6 +1,6 @@ import { router } from 'expo-router'; import { MapPin, Navigation, Route, Search, X } from 'lucide-react-native'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, RefreshControl, ScrollView, View } from 'react-native'; @@ -78,16 +78,33 @@ export const ActiveRoutesList: React.FC = () => { ) : null; - const handleRoutePress = (route: RoutePlanResultData) => { - if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { - const routeInstanceId = activeInstance.RouteInstanceId; - const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; - router.push(activeRouteUrl as any); - return; - } + const handleRoutePress = useCallback( + (route: RoutePlanResultData) => { + if (activeInstance && activeInstance.RoutePlanId === route.RoutePlanId) { + const routeInstanceId = activeInstance.RouteInstanceId; + const activeRouteUrl = routeInstanceId && routeInstanceId !== 'undefined' ? `/routes/active?planId=${route.RoutePlanId}&instanceId=${routeInstanceId}` : `/routes/active?planId=${route.RoutePlanId}`; + router.push(activeRouteUrl as any); + return; + } - router.push(`/routes/start?planId=${route.RoutePlanId}` as any); - }; + router.push(`/routes/start?planId=${route.RoutePlanId}` as any); + }, + [activeInstance] + ); + + // Stable renderItem: an inline closure here is recreated every render, forcing the list to re-render all rows. + const renderRouteItem = useCallback( + ({ item }: { item: RoutePlanResultData }) => { + const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); + const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; + return ( + handleRoutePress(item)}> + + + ); + }, + [activeInstance, activeUnit, activeUnitId, handleRoutePress, unitMap] + ); const filteredRoutes = useMemo(() => { const activeRoutes = routePlans.filter((route) => route.RouteStatus === 1); @@ -139,15 +156,7 @@ export const ActiveRoutesList: React.FC = () => { testID="routes-list" data={filteredRoutes} ListHeaderComponent={activeRouteBanner} - renderItem={({ item }) => { - const isMyUnit = item.UnitId != null && String(item.UnitId) === String(activeUnitId); - const unitName = item.UnitId != null ? unitMap[item.UnitId] || (isMyUnit ? (activeUnit?.Name ?? '') : '') : ''; - return ( - handleRoutePress(item)}> - - - ); - }} + renderItem={renderRouteItem} keyExtractor={(item) => item.RoutePlanId} refreshControl={} ListEmptyComponent={ diff --git a/src/components/sidebar/__tests__/call-sidebar.test.tsx b/src/components/sidebar/__tests__/call-sidebar.test.tsx index 35ddc62b..9442ec46 100644 --- a/src/components/sidebar/__tests__/call-sidebar.test.tsx +++ b/src/components/sidebar/__tests__/call-sidebar.test.tsx @@ -27,11 +27,7 @@ jest.mock('@/lib/navigation'); jest.mock('@/components/ui/bottom-sheet', () => ({ CustomBottomSheet: ({ children, isOpen, onClose, isLoading, testID }: any) => { const { View, Text } = require('react-native'); - return isOpen ? ( - - {isLoading ? Loading... : children} - - ) : null; + return isOpen ? {isLoading ? Loading... : children} : null; }, })); @@ -75,14 +71,18 @@ jest.mock('@/components/ui/hstack', () => ({ })); jest.mock('@/components/ui/button', () => ({ - Button: ({ children, onPress, testID }: any) => { + Button: ({ children, onPress, testID, accessibilityLabel }: any) => { const { TouchableOpacity } = require('react-native'); return ( - + {children} ); }, + ButtonText: ({ children }: any) => { + const { Text } = require('react-native'); + return {children}; + }, ButtonIcon: ({ as: Icon, testID }: any) => { const { View, Text } = require('react-native'); // Create a testID based on the icon type @@ -111,23 +111,43 @@ jest.mock('@/components/ui/button', () => ({ jest.mock('lucide-react-native', () => ({ Check: (props: any) => { const { View, Text } = require('react-native'); - return Check; + return ( + + Check + + ); }, CircleX: (props: any) => { const { View, Text } = require('react-native'); - return CircleX; + return ( + + CircleX + + ); }, Eye: (props: any) => { const { View, Text } = require('react-native'); - return Eye; + return ( + + Eye + + ); }, MapPin: (props: any) => { const { View, Text } = require('react-native'); - return MapPin; + return ( + + MapPin + + ); }, Navigation: (props: any) => { const { View, Text } = require('react-native'); - return Navigation; + return ( + + Navigation + + ); }, })); @@ -215,27 +235,36 @@ describe('SidebarCallCard', () => { toggleColorScheme: jest.fn(), }); - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: null, - activePriority: null, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: null, - activePriority: null, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: null, + activePriority: null, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: null, + activePriority: null, + setActiveCall: mockSetActiveCall, + } + ); - mockUseCallsStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - calls: [], - fetchCalls: mockFetchCalls, - }) : { - calls: [], - fetchCalls: mockFetchCalls, - }); + mockUseCallsStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + calls: [], + fetchCalls: mockFetchCalls, + }) + : { + calls: [], + fetchCalls: mockFetchCalls, + } + ); mockUseQuery.mockReturnValue({ data: [], isLoading: false, + isError: false, error: null, refetch: jest.fn(), } as any); @@ -257,15 +286,19 @@ describe('SidebarCallCard', () => { }); it('should render with active call', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -276,15 +309,19 @@ describe('SidebarCallCard', () => { }); it('should show action buttons when active call exists with coordinates', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -301,15 +338,19 @@ describe('SidebarCallCard', () => { Address: '123 Test Street', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -326,15 +367,19 @@ describe('SidebarCallCard', () => { Address: '', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithoutLocation, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithoutLocation, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithoutLocation, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithoutLocation, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -351,15 +396,19 @@ describe('SidebarCallCard', () => { Address: ' ', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithEmptyAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithEmptyAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithEmptyAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithEmptyAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -429,15 +478,19 @@ describe('SidebarCallCard', () => { describe('Action Buttons', () => { beforeEach(() => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); }); it('should navigate to call detail when eye button is pressed', () => { @@ -456,10 +509,7 @@ describe('SidebarCallCard', () => { expect(mockAlert.alert).toHaveBeenCalledWith( 'calls.confirm_deselect_title', 'calls.confirm_deselect_message', - expect.arrayContaining([ - expect.objectContaining({ text: 'common.cancel' }), - expect.objectContaining({ text: 'common.confirm' }), - ]), + expect.arrayContaining([expect.objectContaining({ text: 'common.cancel' }), expect.objectContaining({ text: 'common.confirm' })]), { cancelable: true } ); }); @@ -469,11 +519,7 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('map-pin-icon')); - expect(mockOpenMapsWithDirections).toHaveBeenCalledWith( - mockCall.Latitude, - mockCall.Longitude, - mockCall.Address - ); + expect(mockOpenMapsWithDirections).toHaveBeenCalledWith(mockCall.Latitude, mockCall.Longitude, mockCall.Address); expect(mockOpenMapsWithAddress).not.toHaveBeenCalled(); }); @@ -485,15 +531,19 @@ describe('SidebarCallCard', () => { Address: '123 Test Street', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -511,13 +561,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('map-pin-icon')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); it('should show error alert when openMapsWithAddress fails', async () => { @@ -528,15 +574,19 @@ describe('SidebarCallCard', () => { Address: '123 Test Street', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); mockOpenMapsWithAddress.mockRejectedValue(new Error('Address navigation failed')); @@ -545,13 +595,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('map-pin-icon')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); it('should show error alert when openMapsWithDirections resolves false', async () => { @@ -562,13 +608,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('map-pin-icon')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); it('should show error alert when openMapsWithAddress resolves false', async () => { @@ -579,15 +621,19 @@ describe('SidebarCallCard', () => { Address: '123 Test Street', }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: callWithAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: callWithAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); mockOpenMapsWithAddress.mockResolvedValue(false); @@ -596,13 +642,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('map-pin-icon')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); }); @@ -617,15 +659,19 @@ describe('SidebarCallCard', () => { }; it('should show destination button when active call has a destination POI', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -633,15 +679,19 @@ describe('SidebarCallCard', () => { }); it('should not show destination button when active call has no destination', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCall, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -649,15 +699,19 @@ describe('SidebarCallCard', () => { }); it('should route to destination coordinates when destination button is pressed', () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -677,15 +731,19 @@ describe('SidebarCallCard', () => { DestinationLongitude: null, }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: destinationAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: destinationAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -704,15 +762,19 @@ describe('SidebarCallCard', () => { DestinationLongitude: 0, }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: destinationZeroCoords, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: destinationZeroCoords, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: destinationZeroCoords, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: destinationZeroCoords, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -731,15 +793,19 @@ describe('SidebarCallCard', () => { DestinationLongitude: 0, }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: destinationZeroNoAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: destinationZeroNoAddress, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: destinationZeroNoAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: destinationZeroNoAddress, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); render(); @@ -747,15 +813,19 @@ describe('SidebarCallCard', () => { }); it('should show error alert when destination openMapsWithDirections resolves false', async () => { - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: mockCallWithDestination, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCallWithDestination, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); mockOpenMapsWithDirections.mockResolvedValue(false); @@ -764,13 +834,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('call-destination-directions-button')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); it('should show error alert when destination openMapsWithAddress resolves false', async () => { @@ -782,15 +848,19 @@ describe('SidebarCallCard', () => { DestinationLongitude: null, }; - mockUseCoreStore.mockImplementation((selector: any) => typeof selector === 'function' ? selector({ - activeCall: destinationAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }) : { - activeCall: destinationAddressOnly, - activePriority: mockPriority, - setActiveCall: mockSetActiveCall, - }); + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: destinationAddressOnly, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); mockOpenMapsWithAddress.mockResolvedValue(false); @@ -799,13 +869,9 @@ describe('SidebarCallCard', () => { fireEvent.press(screen.getByTestId('call-destination-directions-button')); // Wait for the async operation to complete - await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockAlert.alert).toHaveBeenCalledWith( - 'calls.no_location_title', - 'calls.no_location_message', - [{ text: 'common.ok' }] - ); + expect(mockAlert.alert).toHaveBeenCalledWith('calls.no_location_title', 'calls.no_location_message', [{ text: 'common.ok' }]); }); }); @@ -815,5 +881,76 @@ describe('SidebarCallCard', () => { expect(screen.getByTestId('call-selection-trigger')).toBeTruthy(); }); + + it('labels the icon-only action buttons', () => { + mockUseCoreStore.mockImplementation((selector: any) => + typeof selector === 'function' + ? selector({ + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + }) + : { + activeCall: mockCall, + activePriority: mockPriority, + setActiveCall: mockSetActiveCall, + } + ); + + const { unmount } = render(); + + // Eye / MapPin / CircleX are icon-only, so a screen reader has nothing to + // announce without an explicit label. + expect(screen.getByLabelText('map.view_call_details')).toBeTruthy(); + expect(screen.getByLabelText('calls.directions')).toBeTruthy(); + expect(screen.getByLabelText('calls.deselect')).toBeTruthy(); + + unmount(); + }); + }); + + describe('open-calls load failure', () => { + it('shows a translated error and a retry control instead of an empty sheet', () => { + const mockRefetch = jest.fn(); + mockUseQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Failed to fetch calls'), + refetch: mockRefetch, + } as any); + + const { unmount } = render(); + + fireEvent.press(screen.getByTestId('call-selection-trigger')); + + expect(screen.getByText('calls.errors.load_failed')).toBeTruthy(); + // The "no open calls" message must NOT be what a failed load looks like + expect(screen.queryByTestId('no-calls-message')).toBeNull(); + + fireEvent.press(screen.getByTestId('call-selection-retry-button')); + expect(mockRefetch).toHaveBeenCalled(); + + unmount(); + }); + + it('still renders the call list when the query succeeds', () => { + mockUseQuery.mockReturnValue({ + data: [mockCall], + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + } as any); + + const { unmount } = render(); + + fireEvent.press(screen.getByTestId('call-selection-trigger')); + + expect(screen.queryByText('calls.errors.load_failed')).toBeNull(); + expect(screen.getByTestId(`call-item-${mockCall.CallId}`)).toBeTruthy(); + + unmount(); + }); }); -}); \ No newline at end of file +}); diff --git a/src/components/sidebar/call-sidebar.tsx b/src/components/sidebar/call-sidebar.tsx index 5c2ac3ed..fe30ac49 100644 --- a/src/components/sidebar/call-sidebar.tsx +++ b/src/components/sidebar/call-sidebar.tsx @@ -9,12 +9,13 @@ import { Alert, Platform, Pressable, ScrollView } from 'react-native'; import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { logger } from '@/lib/logging'; import { openMapsWithAddress, openMapsWithDirections } from '@/lib/navigation'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { CallCard } from '../calls/call-card'; -import { Button, ButtonIcon } from '../ui/button'; +import { Button, ButtonIcon, ButtonText } from '../ui/button'; import { Card } from '../ui/card'; import { HStack } from '../ui/hstack'; @@ -27,18 +28,34 @@ export const SidebarCallCard = () => { const [isBottomSheetOpen, setIsBottomSheetOpen] = React.useState(false); const { t } = useTranslation(); - // Fetch calls data when bottom sheet opens - const { data: openCallsData, isLoading } = useQuery({ + // Fetch calls data when bottom sheet opens. + // The store swallows fetch failures into its own `error` field and never rejects, so + // the query has to read that field and throw — otherwise a failed load rendered an + // empty sheet that looked like "there are no open calls". + const { + data: openCallsData, + isLoading, + isError, + refetch, + } = useQuery({ queryKey: ['calls', 'open'], queryFn: async () => { // Only fetch when bottom sheet is open if (!isBottomSheetOpen) return []; - await useCallsStore.getState().fetchCalls(); - return useCallsStore.getState().calls; + await useCallsStore.getState().fetchCalls(true); + const { calls, error } = useCallsStore.getState(); + if (error) { + throw new Error(error); + } + return calls; }, enabled: isBottomSheetOpen, // Only run query when bottom sheet is open }); + const handleRetry = React.useCallback(() => { + void refetch(); + }, [refetch]); + const handleDeselect = () => { if (Platform.OS === 'web') { const confirmed = window.confirm(`${t('calls.confirm_deselect_title')}\n${t('calls.confirm_deselect_message')}`); @@ -170,13 +187,14 @@ export const SidebarCallCard = () => { )} - {activeCall && ( + {activeCall ? ( - {hasLocationData(activeCall) && ( - - )} + ) : null} {hasDestinationData(activeCall) ? ( - ) : null} - - )} + ) : null} setIsBottomSheetOpen(false)} isLoading={isLoading} loadingText={t('common.loading')} snapPoints={[60]} testID="call-selection-bottom-sheet"> {t('calls.select_active_call')} - - - {openCallsData?.map((call) => ( - { - const handleCallSelect = async () => { - try { - await setActiveCall(call.CallId); - setIsBottomSheetOpen(false); - } catch (error) { - console.error('Failed to set active call:', error); - } - }; - handleCallSelect().catch((error) => { - console.error('Failed to handle call selection:', error); - }); - }} - className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${ - activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : '' - }`} - testID={`call-item-${call.CallId}`} - > - - - {call.Name} - - {call.Type} - - - {activeCall?.CallId === call.CallId && } - - - ))} - {!isLoading && openCallsData?.length === 0 && ( - - {t('calls.no_open_calls')} - - )} + {isError ? ( + + {t('calls.errors.load_failed')} + - + ) : ( + + + {openCallsData?.map((call) => ( + { + const handleCallSelect = async () => { + try { + await setActiveCall(call.CallId); + setIsBottomSheetOpen(false); + } catch (error) { + logger.error({ message: 'Failed to set active call', context: { error, callId: call.CallId } }); + } + }; + handleCallSelect().catch((error) => { + logger.error({ message: 'Failed to handle call selection', context: { error, callId: call.CallId } }); + }); + }} + className={`rounded-lg border p-4 ${colorScheme === 'dark' ? 'border-neutral-800 bg-neutral-800' : 'border-neutral-200 bg-neutral-50'} ${ + activeCall?.CallId === call.CallId ? (colorScheme === 'dark' ? 'bg-primary-900' : 'bg-primary-50') : '' + }`} + testID={`call-item-${call.CallId}`} + > + + + {call.Name} + + {call.Type} + + + {activeCall?.CallId === call.CallId ? : null} + + + ))} + {!isLoading && openCallsData?.length === 0 ? ( + + {t('calls.no_open_calls')} + + ) : null} + + + )} diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index 287af309..898f0d38 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -52,7 +52,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { {/* First row - Two cards side by side */} - + @@ -66,7 +66,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { {/* Chat + Assistant navigation (hidden when the Chat.System feature flag is off) */} - {isChatEnabled && ( + {isChatEnabled ? ( - )} + ) : null} {/* Third row - Status buttons or empty state */} {isActiveStatusesEmpty ? ( diff --git a/src/components/sidebar/unit-sidebar.tsx b/src/components/sidebar/unit-sidebar.tsx index 5457bd7d..4af5b509 100644 --- a/src/components/sidebar/unit-sidebar.tsx +++ b/src/components/sidebar/unit-sidebar.tsx @@ -1,5 +1,6 @@ import { Lock, Mic, Phone, Radio, Unlock } from 'lucide-react-native'; import * as React from 'react'; +import { useTranslation } from 'react-i18next'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; import { AudioStreamBottomSheet } from '@/components/audio-stream/audio-stream-bottom-sheet'; @@ -19,6 +20,7 @@ type ItemProps = { }; export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUnitType, unitGroup: defaultUnitGroup, bgColor }: ItemProps) => { + const { t } = useTranslation(); const activeUnit = useCoreStore((state) => state.activeUnit); const setIsBottomSheetVisible = useLiveKitStore((state) => state.setIsBottomSheetVisible); const ensureMicrophonePermission = useLiveKitStore((state) => state.ensureMicrophonePermission); @@ -70,15 +72,35 @@ export const SidebarUnitCard = ({ unitName: defaultUnitName, unitType: defaultUn ) : null} - + {isMapLocked ? : } - + - + diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx index a8958bff..d1ef81e0 100644 --- a/src/components/status/__tests__/status-bottom-sheet.test.tsx +++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx @@ -3,6 +3,12 @@ jest.mock('react-i18next', () => ({ useTranslation: jest.fn(), })); +// Controllable keyboard height (plain function, so jest.clearAllMocks can't wipe it) +let mockKeyboardHeight = 0; +jest.mock('@/hooks/use-keyboard-height', () => ({ + useKeyboardHeight: () => mockKeyboardHeight, +})); + // Mock all UI components based on actual imports jest.mock('@/components/ui/actionsheet', () => { const mockReact = require('react'); @@ -824,6 +830,43 @@ describe('StatusBottomSheet', () => { expect(screen.getByText('Submit')).toBeTruthy(); }); + it('should scroll the note step to the end when the keyboard opens', async () => { + const { ScrollView } = require('react-native'); + const scrollToEndSpy = jest.spyOn(ScrollView.prototype, 'scrollToEnd').mockImplementation(() => {}); + + const selectedStatus = { + Id: 'status-1', + Text: 'Available', + Detail: 1, + Note: 1, + }; + + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { + const store = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + currentStep: 'add-note', + }; + if (selector) { + return selector(store); + } + return store; + }); + + try { + mockKeyboardHeight = 300; + render(); + + await waitFor(() => { + expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: true }); + }); + } finally { + mockKeyboardHeight = 0; + scrollToEndSpy.mockRestore(); + } + }); + it('should handle previous button on note step', () => { const selectedStatus = { Id: 'status-1', @@ -1850,6 +1893,73 @@ describe('StatusBottomSheet', () => { }); }); + it('should scroll the auto-selected active call into view once its row lays out', async () => { + const { ScrollView } = require('react-native'); + const scrollToSpy = jest.spyOn(ScrollView.prototype, 'scrollTo').mockImplementation(() => {}); + + const activeCall = { + CallId: 'active-call-123', + Number: 'C123', + Name: 'Active Emergency Call', + Address: '123 Active St', + }; + + const selectedStatus = { + Id: 'status-1', + Text: 'Responding', + Detail: 2, // Show calls + Note: 0, + }; + + const coreStoreWithActiveCall = { + ...defaultCoreStore, + activeCallId: 'active-call-123', + }; + mockGetState.mockReturnValue(coreStoreWithActiveCall as any); + mockUseCoreStore.mockImplementation((selector: any) => { + if (selector) { + return selector(coreStoreWithActiveCall); + } + return coreStoreWithActiveCall; + }); + + mockUseStatusBottomSheetStore.mockImplementation((selector: any) => { + const store = { + ...defaultBottomSheetStore, + isOpen: true, + selectedStatus, + availableCalls: [{ CallId: 'other-call-456', Number: 'C456', Name: 'Other Call', Address: '456 Other St' }, activeCall], + isLoading: false, + selectedCall: null, + selectedDestinationType: 'none', + }; + if (selector) { + return selector(store); + } + return store; + }); + + render(); + + await waitFor(() => { + expect(mockSetSelectedCall).toHaveBeenCalledWith(activeCall); + }); + + // The active call's row reports its position — the list should scroll to it + const activeRow = screen.getByText('C123 - Active Emergency Call'); + fireEvent(activeRow, 'layout', { nativeEvent: { layout: { x: 0, y: 240, width: 320, height: 76 } } }); + + expect(scrollToSpy).toHaveBeenCalledWith({ y: 232, animated: true }); + + // A different row laying out must not scroll (only the pending active call does) + scrollToSpy.mockClear(); + const otherRow = screen.getByText('C456 - Other Call'); + fireEvent(otherRow, 'layout', { nativeEvent: { layout: { x: 0, y: 0, width: 320, height: 76 } } }); + expect(scrollToSpy).not.toHaveBeenCalled(); + + scrollToSpy.mockRestore(); + }); + it('should not pre-select active call when calls are not enabled (detailLevel 1)', () => { const activeCall = { CallId: 'active-call-123', diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index 53646503..4fc7ccab 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, ArrowRight, Check } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, TouchableOpacity } from 'react-native'; +import { InteractionManager, ScrollView, TouchableOpacity } from 'react-native'; import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { logger } from '@/lib/logging'; @@ -173,6 +173,64 @@ export const StatusBottomSheet = () => { return availableCalls.find((call) => call.CallId === activeCallId) ?? null; }, [activeCallId, availableCalls]); + // When the active call is auto-selected the destination list should scroll it + // into view. The id is parked here until the selected row's onLayout reports + // where it landed (the list may not even be mounted yet — e.g. the user is + // still on the status step). + const destinationScrollRef = React.useRef(null); + const pendingScrollToCallIdRef = React.useRef(null); + // Row offsets captured from onLayout. onLayout only fires when a row is + // (re)measured, so a scroll request armed after the list already settled would + // otherwise never be acted on — these let it resolve immediately instead. + const callRowOffsetsRef = React.useRef>({}); + + const scrollDestinationToOffset = React.useCallback((y: number) => { + destinationScrollRef.current?.scrollTo({ y: Math.max(0, y - 8), animated: true }); + }, []); + + /** Run a pending scroll request as soon as the target row's offset is known. */ + const flushPendingCallScroll = React.useCallback(() => { + const callId = pendingScrollToCallIdRef.current; + if (!callId) { + return; + } + + const offset = callRowOffsetsRef.current[callId]; + if (offset == null) { + return; + } + + pendingScrollToCallIdRef.current = null; + scrollDestinationToOffset(offset); + }, [scrollDestinationToOffset]); + + React.useEffect(() => { + if (!isOpen) { + pendingScrollToCallIdRef.current = null; + callRowOffsetsRef.current = {}; + } + }, [isOpen]); + + // The keyboard padding on ActionsheetContent reserves the covered strip, but it + // doesn't move the note field there — if the field sits below the fold it stays + // hidden under the keyboard. The note and its submit button are the last content + // in both note-entry steps, so scrolling to the end brings them into view once + // the padded layout settles. + const noteScrollRef = React.useRef(null); + const isOnNoteEntryStep = currentStep === 'add-note' || (currentStep === 'select-destination' && !shouldShowDestinationStep); + + React.useEffect(() => { + if (keyboardHeight <= 0 || !isOnNoteEntryStep) { + return; + } + + const timeoutId = setTimeout(() => { + noteScrollRef.current?.scrollToEnd({ animated: true }); + }, 50); + + return () => clearTimeout(timeoutId); + }, [keyboardHeight, isOnNoteEntryStep]); + React.useEffect(() => { if (isOpen && activeUnit) { fetchDestinationData(activeUnit.UnitId); @@ -239,7 +297,13 @@ export const StatusBottomSheet = () => { setSelectedCall(activeCallCandidate); setSelectedDestinationType('call'); - }, [activeCallCandidate, detailLevel, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]); + pendingScrollToCallIdRef.current = activeCallCandidate.CallId; + + // If the rows are already laid out no further onLayout will fire, so drive + // the scroll ourselves once the current interactions settle. + const interaction = InteractionManager.runAfterInteractions(flushPendingCallScroll); + return () => interaction.cancel(); + }, [activeCallCandidate, detailLevel, flushPendingCallScroll, isOpen, selectedCall, selectedDestinationType, selectedPoi, selectedStation, selectedStatus, setSelectedCall, setSelectedDestinationType]); // Auto-pick the initial tab when the sheet opens or the status changes. // After that the user's manual tab taps must win — recomputing the preferred @@ -628,7 +692,10 @@ export const StatusBottomSheet = () => { - + {/* flex-1 (not just shrink) so the column fills the snap-point-sized sheet: + the step's list flexes into the space and the action buttons sit at the + bottom instead of floating above a dead zone. */} + {t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()} @@ -640,10 +707,10 @@ export const StatusBottomSheet = () => { {currentStep === 'select-status' ? ( - + {t('status.select_status_type')} - + {activeStatuses?.Statuses && activeStatuses.Statuses.length > 0 ? ( activeStatuses.Statuses.map((status) => { @@ -691,7 +758,7 @@ export const StatusBottomSheet = () => { ) : null} {currentStep === 'select-destination' && shouldShowDestinationStep ? ( - + {t('status.select_destination_type')} { ) : null} - + {showCalls ? ( {isLoading ? ( @@ -730,6 +797,16 @@ export const StatusBottomSheet = () => { handleCallSelect(call.CallId)} + onLayout={(event) => { + // Bring the auto-selected active call into view once + // its row reports where it landed in the list. + callRowOffsetsRef.current[call.CallId] = event.nativeEvent.layout.y; + if (pendingScrollToCallIdRef.current !== call.CallId) { + return; + } + pendingScrollToCallIdRef.current = null; + scrollDestinationToOffset(event.nativeEvent.layout.y); + }} className={`mb-3 rounded-lg border-2 p-3 ${selectedCall?.CallId === call.CallId ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800'}`} > @@ -834,32 +911,44 @@ export const StatusBottomSheet = () => { ) : null} {currentStep === 'select-destination' && !shouldShowDestinationStep ? ( - - {isNoteRequired || isNoteOptional ? ( - <> - {t('status.add_note')} - - - ) : null} - - {cameFromStatusSelection ? ( - - ) : ( - + ) : ( + + )} + - )} - - - + + + ) : null} {currentStep === 'add-note' ? ( @@ -867,7 +956,13 @@ export const StatusBottomSheet = () => { the ActionsheetContent paddingBottom. KeyboardAwareScrollView also reacts to the keyboard (its events are window-agnostic), so it compensated a second time and pushed the note field out of the sheet's visible area. */ - + {t('status.selected_status')}: diff --git a/src/hooks/__tests__/use-quick-check-in.test.ts b/src/hooks/__tests__/use-quick-check-in.test.ts index 19753d76..2f538ec5 100644 --- a/src/hooks/__tests__/use-quick-check-in.test.ts +++ b/src/hooks/__tests__/use-quick-check-in.test.ts @@ -40,14 +40,18 @@ jest.mock('@/stores/app/core-store', () => ({ ), })); -jest.mock('@/stores/app/location-store', () => ({ - useLocationStore: jest.fn((selector: any) => - selector({ - latitude: 40.7128, - longitude: -74.006, - }) - ), -})); +// The hook reads location via getState() inside quickCheckIn rather than subscribing, +// so the mock has to expose getState alongside the selector call signature. +const mockLocationState = { + latitude: 40.7128, + longitude: -74.006, +}; + +jest.mock('@/stores/app/location-store', () => { + const useLocationStore: any = jest.fn((selector: any) => selector(mockLocationState)); + useLocationStore.getState = jest.fn(() => mockLocationState); + return { useLocationStore }; +}); jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn((selector: any) => diff --git a/src/hooks/use-quick-check-in.ts b/src/hooks/use-quick-check-in.ts index 64bbe0cb..74c235eb 100644 --- a/src/hooks/use-quick-check-in.ts +++ b/src/hooks/use-quick-check-in.ts @@ -14,13 +14,14 @@ export function useQuickCheckIn(callId: number, checkInType?: number) { const isCheckingIn = useCheckInTimerStore((state) => state.isCheckingIn); const performCheckInAction = useCheckInTimerStore((state) => state.performCheckIn); const activeUnit = useCoreStore((state) => state.activeUnit); - const latitude = useLocationStore((state) => state.latitude); - const longitude = useLocationStore((state) => state.longitude); const showToast = useToastStore((state) => state.showToast); + // NOTE: location is read via useLocationStore.getState() inside quickCheckIn instead + // of subscribing — subscribing re-rendered every consumer of this hook on each GPS fix. const quickCheckIn = useCallback(async () => { const resolvedCheckInType = checkInType ?? (activeUnit ? CHECK_IN_TARGET_TYPE.UNIT_TYPE : CHECK_IN_TARGET_TYPE.PERSONNEL); const shouldUseActiveUnit = resolvedCheckInType === CHECK_IN_TARGET_TYPE.UNIT_TYPE && activeUnit !== null; + const { latitude, longitude } = useLocationStore.getState(); const input: PerformCheckInInput = { CallId: callId, CheckInType: resolvedCheckInType, @@ -40,7 +41,7 @@ export function useQuickCheckIn(callId: number, checkInType?: number) { } return result; - }, [callId, checkInType, activeUnit, latitude, longitude, performCheckInAction, showToast, t]); + }, [callId, checkInType, activeUnit, performCheckInAction, showToast, t]); return { quickCheckIn, isCheckingIn }; } diff --git a/src/lib/__tests__/map-camera.test.ts b/src/lib/__tests__/map-camera.test.ts new file mode 100644 index 00000000..ec35bd22 --- /dev/null +++ b/src/lib/__tests__/map-camera.test.ts @@ -0,0 +1,238 @@ +import { applyPitchHysteresis, applyZoomHysteresis, createCirclePolygon, FOLLOW_PITCH_MOVING, normalizeHeading, normalizeSpeed, smoothSpeed, wrapLongitude, zoomForSpeed } from '../map-camera'; + +describe('zoomForSpeed', () => { + it('returns the tightest zoom when stationary', () => { + expect(zoomForSpeed(0)).toBe(17); + }); + + it('treats invalid speeds as stationary', () => { + expect(zoomForSpeed(-1)).toBe(17); + expect(zoomForSpeed(NaN)).toBe(17); + }); + + it('stays near street level at walking pace', () => { + // ~1.4 m/s = typical walking speed + const zoom = zoomForSpeed(1.4); + expect(zoom).toBeGreaterThan(16); + expect(zoom).toBeLessThanOrEqual(17); + }); + + it('zooms out for urban driving speeds', () => { + // ~13.4 m/s = 30 mph + const zoom = zoomForSpeed(13.4); + expect(zoom).toBeGreaterThan(14); + expect(zoom).toBeLessThan(15.5); + }); + + it('zooms out further at highway speed', () => { + // ~29 m/s = 65 mph + const zoom = zoomForSpeed(29); + expect(zoom).toBeGreaterThan(12.5); + expect(zoom).toBeLessThan(13.5); + }); + + it('clamps at the widest zoom for extreme speeds', () => { + expect(zoomForSpeed(100)).toBe(12.5); + }); + + it('is monotonically non-increasing as speed rises', () => { + let previous = Infinity; + for (let speed = 0; speed <= 45; speed += 0.5) { + const zoom = zoomForSpeed(speed); + expect(zoom).toBeLessThanOrEqual(previous); + previous = zoom; + } + }); + + it('interpolates linearly between stops', () => { + // Midpoint of the [4, 16] → [9, 15] segment + expect(zoomForSpeed(6.5)).toBeCloseTo(15.5, 5); + }); +}); + +describe('normalizeHeading', () => { + it('passes through valid headings', () => { + expect(normalizeHeading(0)).toBe(0); + expect(normalizeHeading(180)).toBe(180); + expect(normalizeHeading(359.9)).toBeCloseTo(359.9); + }); + + it('wraps headings of 360 and above', () => { + expect(normalizeHeading(360)).toBe(0); + expect(normalizeHeading(450)).toBe(90); + }); + + it('returns null for missing or invalid headings', () => { + expect(normalizeHeading(null)).toBeNull(); + expect(normalizeHeading(undefined)).toBeNull(); + // iOS reports "no heading fix" as -1 + expect(normalizeHeading(-1)).toBeNull(); + expect(normalizeHeading(NaN)).toBeNull(); + }); +}); + +describe('normalizeSpeed', () => { + it('passes through valid speeds', () => { + expect(normalizeSpeed(5)).toBe(5); + }); + + it('clamps missing or invalid speeds to zero', () => { + expect(normalizeSpeed(null)).toBe(0); + expect(normalizeSpeed(undefined)).toBe(0); + // iOS reports "no speed fix" as -1 + expect(normalizeSpeed(-1)).toBe(0); + expect(normalizeSpeed(NaN)).toBe(0); + }); +}); + +describe('smoothSpeed', () => { + it('adopts the first sample directly', () => { + expect(smoothSpeed(null, 10)).toBe(10); + }); + + it('moves partway toward the new sample', () => { + expect(smoothSpeed(0, 10, 0.4)).toBeCloseTo(4); + expect(smoothSpeed(10, 0, 0.4)).toBeCloseTo(6); + }); + + it('converges to a steady speed over repeated samples', () => { + let smoothed: number | null = null; + for (let i = 0; i < 30; i++) { + smoothed = smoothSpeed(smoothed, 20); + } + expect(smoothed).toBeCloseTo(20, 1); + }); + + it('normalizes invalid samples to zero', () => { + expect(smoothSpeed(10, -1, 0.5)).toBe(5); + }); +}); + +describe('applyZoomHysteresis', () => { + it('adopts the target when there is no current zoom', () => { + expect(applyZoomHysteresis(null, 16)).toBe(16); + }); + + it('keeps the current zoom for changes below the threshold', () => { + expect(applyZoomHysteresis(16, 16.1)).toBe(16); + expect(applyZoomHysteresis(16, 15.9)).toBe(16); + }); + + it('adopts the target for changes at or above the threshold', () => { + expect(applyZoomHysteresis(16, 16.5)).toBe(16.5); + expect(applyZoomHysteresis(16, 15)).toBe(15); + }); +}); + +describe('applyPitchHysteresis', () => { + it('tilts up once clearly moving', () => { + expect(applyPitchHysteresis(0, 2)).toBe(FOLLOW_PITCH_MOVING); + }); + + it('returns to top-down once clearly stopped', () => { + expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, 0.2)).toBe(0); + }); + + it('holds the current pitch inside the dead band', () => { + // Between the tilt-down (0.7) and tilt-up (1.5) thresholds the pitch sticks, + // so a speed hovering around 1 m/s can't flip the camera every fix. + expect(applyPitchHysteresis(0, 1)).toBe(0); + expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, 1)).toBe(FOLLOW_PITCH_MOVING); + }); + + it('does not oscillate when speed jitters around 1 m/s', () => { + const speeds = [0.9, 1.1, 0.95, 1.2, 1.05, 0.85]; + let pitch = 0; + for (const speed of speeds) { + pitch = applyPitchHysteresis(pitch, speed); + expect(pitch).toBe(0); + } + }); + + it('defaults to top-down when there is no previous pitch', () => { + expect(applyPitchHysteresis(null, 1)).toBe(0); + }); + + it('treats invalid speeds as stationary', () => { + expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, -1)).toBe(0); + expect(applyPitchHysteresis(FOLLOW_PITCH_MOVING, NaN)).toBe(0); + }); +}); + +describe('wrapLongitude', () => { + it('passes through in-range longitudes', () => { + expect(wrapLongitude(0)).toBe(0); + expect(wrapLongitude(-122.4)).toBeCloseTo(-122.4); + }); + + it('wraps longitudes past the antimeridian', () => { + expect(wrapLongitude(181)).toBeCloseTo(-179); + expect(wrapLongitude(-181)).toBeCloseTo(179); + expect(wrapLongitude(540)).toBeCloseTo(-180); + }); +}); + +describe('createCirclePolygon', () => { + it('produces a closed polygon ring', () => { + const circle = createCirclePolygon(-122.4, 47.6, 100); + const ring = circle.geometry.coordinates[0]; + expect(ring.length).toBe(65); + expect(ring[0]).toEqual(ring[ring.length - 1]); + }); + + it('closes the ring exactly rather than relying on sin/cos at 2π', () => { + const ring = createCirclePolygon(-122.4, 47.6, 100).geometry.coordinates[0]; + const first = ring[0]; + const last = ring[ring.length - 1]; + // Strict equality — GeoJSON requires an identical closing position, and + // computing it from sin(2π)/cos(2π) leaves floating point residue. + expect(last[0]).toBe(first[0]); + expect(last[1]).toBe(first[1]); + }); + + it('normalizes a center longitude given outside [-180, 180)', () => { + const wrapped = createCirclePolygon(181, 0, 100).geometry.coordinates[0]; + const direct = createCirclePolygon(-179, 0, 100).geometry.coordinates[0]; + expect(wrapped[0][0]).toBeCloseTo(direct[0][0], 9); + }); + + it('keeps a ring spanning the antimeridian contiguous', () => { + // Centered just west of the antimeridian with a large radius, the ring's + // eastern vertices run past +180. They must stay contiguous (not jump to + // -180) or the polygon smears right across the map. + const ring = createCirclePolygon(179.999, 0, 50000).geometry.coordinates[0]; + const lons = ring.map((c) => c[0]); + expect(Math.max(...lons)).toBeGreaterThan(180); + for (let i = 1; i < lons.length; i++) { + expect(Math.abs(lons[i] - lons[i - 1])).toBeLessThan(180); + } + }); + + it('clamps latitudes so a polar circle stays a valid coordinate', () => { + const ring = createCirclePolygon(0, 89.999, 200000).geometry.coordinates[0]; + ring.forEach(([, lat]) => { + expect(lat).toBeLessThanOrEqual(90); + expect(lat).toBeGreaterThanOrEqual(-90); + }); + }); + + it('centers the ring on the given coordinate', () => { + const circle = createCirclePolygon(-122.4, 47.6, 100); + const ring = circle.geometry.coordinates[0]; + const avgLon = ring.slice(0, -1).reduce((sum, c) => sum + c[0], 0) / (ring.length - 1); + const avgLat = ring.slice(0, -1).reduce((sum, c) => sum + c[1], 0) / (ring.length - 1); + expect(avgLon).toBeCloseTo(-122.4, 5); + expect(avgLat).toBeCloseTo(47.6, 5); + }); + + it('widens longitude spacing away from the equator so the circle stays round', () => { + const equator = createCirclePolygon(0, 0, 1000); + const north = createCirclePolygon(0, 60, 1000); + const lonSpan = (feature: GeoJSON.Feature) => { + const lons = feature.geometry.coordinates[0].map((c) => c[0]); + return Math.max(...lons) - Math.min(...lons); + }; + // cos(60°) = 0.5 → the ring must span about twice as many degrees of longitude + expect(lonSpan(north) / lonSpan(equator)).toBeCloseTo(2, 1); + }); +}); diff --git a/src/lib/auth/__tests__/api-timeout.test.ts b/src/lib/auth/__tests__/api-timeout.test.ts new file mode 100644 index 00000000..06691aac --- /dev/null +++ b/src/lib/auth/__tests__/api-timeout.test.ts @@ -0,0 +1,50 @@ +/** + * The auth client talks to /connect/token. Without a timeout a hung request pins + * the single-flight refresh promise (lib/auth/refresh-lock.ts) and every request + * queued behind a 401 waits on it forever. + */ +const mockCreateConfigs: Record[] = []; + +const mockAuthApiInstance = Object.assign(jest.fn(), { + interceptors: { + request: { use: jest.fn() }, + response: { use: jest.fn() }, + }, + post: jest.fn(), +}); + +jest.mock('axios', () => ({ + __esModule: true, + default: { + create: jest.fn((config: Record) => { + mockCreateConfigs.push(config); + return mockAuthApiInstance; + }), + }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock('@/lib/storage/app', () => ({ + getBaseApiUrl: jest.fn(() => 'https://example.test'), +})); + +describe('auth API client configuration', () => { + beforeAll(() => { + jest.isolateModules(() => { + require('@/lib/auth/api'); + }); + }); + + it('sets a timeout on the token endpoint client', () => { + expect(mockCreateConfigs[0]).toMatchObject({ timeout: 15000 }); + }); + + it('keeps the timeout shorter than the main API client timeout', () => { + // The refresh must give up before a request waiting on it would, so a stalled + // refresh surfaces as a transient failure rather than cascading timeouts. + expect(mockCreateConfigs[0].timeout as number).toBeLessThan(30000); + }); +}); diff --git a/src/lib/auth/__tests__/jwt.test.ts b/src/lib/auth/__tests__/jwt.test.ts new file mode 100644 index 00000000..5883b68c --- /dev/null +++ b/src/lib/auth/__tests__/jwt.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from '@jest/globals'; + +import { decodeJwtPayload, getJwtExpiryMs } from '../jwt'; + +/** Build a JWT whose payload is base64url-encoded (RFC 7515): '-'/'_' alphabet, no padding. */ +const makeJwt = (payload: Record): string => { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `header.${encoded}.signature`; +}; + +describe('decodeJwtPayload', () => { + it('decodes a payload containing base64url characters (- and _)', () => { + // These byte sequences force '+' and '/' in standard base64, which become + // '-' and '_' in base64url — the exact input react-native-base64 rejects. + const payload = { sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 }; + const token = makeJwt(payload); + + // Guard: the fixture really does exercise the base64url alphabet. + expect(token.split('.')[1]).toMatch(/[-_]/); + + expect(JSON.parse(decodeJwtPayload(token))).toMatchObject({ sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 }); + }); + + // react-native-base64 hands back one character per byte (latin1), so without + // reinterpreting those bytes as UTF-8 every non-ASCII display name arrives + // mojibake'd — "Tëst" as "Tëst". The app ships nine non-English locales, so + // departments really do have names outside ASCII. + it.each([ + ['Latin accents', 'Tëst Ünit Nº3'], + ['Cyrillic', 'Пожежна частина 7'], + ['Greek', 'Πυροσβεστική Μονάδα'], + ['Arabic', 'وحدة الإطفاء'], + ['four-byte sequences', 'Station 🚒 Alpha'], + ])('round-trips %s in a name claim', (_label, name) => { + expect(JSON.parse(decodeJwtPayload(makeJwt({ sub: 'user-1', name })))).toMatchObject({ name }); + }); + + it('falls back to the raw bytes rather than throwing on invalid UTF-8', () => { + // 0x80 is a bare continuation byte — not a legal UTF-8 sequence start. + const encoded = Buffer.from([0x80, 0x41]).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + expect(() => decodeJwtPayload(`header.${encoded}.signature`)).not.toThrow(); + }); + + it('decodes an unpadded payload (base64url drops = padding)', () => { + const token = makeJwt({ sub: 'abc' }); + expect(token.split('.')[1]).not.toContain('='); + expect(JSON.parse(decodeJwtPayload(token))).toEqual({ sub: 'abc' }); + }); + + it('throws when the token has no payload segment', () => { + expect(() => decodeJwtPayload('not-a-jwt')).toThrow('Invalid JWT: missing payload segment'); + }); +}); + +describe('getJwtExpiryMs', () => { + it('returns the exp claim converted to epoch milliseconds', () => { + expect(getJwtExpiryMs(makeJwt({ sub: 'a', exp: 1893456000 }))).toBe(1893456000 * 1000); + }); + + it('reads exp from a payload that uses base64url characters', () => { + const token = makeJwt({ sub: 'user-1', name: 'Unit ~~~ ûÿ', exp: 1893456000 }); + expect(getJwtExpiryMs(token)).toBe(1893456000 * 1000); + }); + + it('returns null when there is no numeric exp claim', () => { + expect(getJwtExpiryMs(makeJwt({ sub: 'a' }))).toBeNull(); + expect(getJwtExpiryMs(makeJwt({ sub: 'a', exp: 'soon' }))).toBeNull(); + }); + + it('returns null for an opaque (non-JWT) token instead of throwing', () => { + expect(getJwtExpiryMs('opaque-access-token')).toBeNull(); + expect(getJwtExpiryMs('')).toBeNull(); + }); +}); diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index b429f059..d5d293a5 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -9,6 +9,10 @@ import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials const authApi = axios.create({ baseURL: getBaseApiUrl(), + // Axios defaults to no timeout — a hung /connect/token call would otherwise + // pin the single-flight refresh promise and stall every queued 401 retry. A + // timeout surfaces as a transient failure (no response), never a logout. + timeout: 15000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, diff --git a/src/lib/auth/index.tsx b/src/lib/auth/index.tsx index 305bb219..7bfed690 100644 --- a/src/lib/auth/index.tsx +++ b/src/lib/auth/index.tsx @@ -10,7 +10,7 @@ export * from './types'; // Utility hooks and selectors export const useAuth = () => { - const { accessToken, status, error, login, ssoLogin, logout, hydrate } = useAuthStore( + const { accessToken, status, error, login, ssoLogin, logout } = useAuthStore( useShallow((state) => ({ accessToken: state.accessToken, status: state.status, @@ -18,7 +18,6 @@ export const useAuth = () => { login: state.login, ssoLogin: state.ssoLogin, logout: state.logout, - hydrate: state.hydrate, })) ); return { @@ -29,6 +28,5 @@ export const useAuth = () => { ssoLogin, logout, status, - hydrate, }; }; diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts new file mode 100644 index 00000000..6730a364 --- /dev/null +++ b/src/lib/auth/jwt.ts @@ -0,0 +1,51 @@ +import base64 from 'react-native-base64'; + +/** + * Reinterpret a byte string as UTF-8. + * + * react-native-base64's decode() returns one character per byte (latin1), so a + * multi-byte claim such as a name with an accent arrives mojibake'd ("Tëst" + * instead of "Tëst"). Percent-encoding each byte and letting decodeURIComponent + * reassemble the sequences recovers the original text. Falls back to the raw + * byte string if the bytes are not valid UTF-8, so this can never throw where + * the previous latin1 behavior merely garbled. + */ +const bytesToUtf8 = (binary: string): string => { + try { + return decodeURIComponent(Array.from(binary, (char) => `%${(char.charCodeAt(0) & 0xff).toString(16).padStart(2, '0')}`).join('')); + } catch { + return binary; + } +}; + +/** + * Decode the payload (second segment) of a JWT to its JSON string. + * + * JWT segments are base64url-encoded (RFC 7515): the alphabet uses '-' and '_' + * instead of '+' and '/', and drops '=' padding. react-native-base64's decode() + * only understands the standard alphabet and throws on '-'/'_', so normalize to + * standard base64 and re-pad before decoding. + */ +export const decodeJwtPayload = (token: string): string => { + const segment = token.split('.')[1]; + if (!segment) { + throw new Error('Invalid JWT: missing payload segment'); + } + + const normalized = segment.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + return bytesToUtf8(base64.decode(padded)); +}; + +/** + * Best-effort extraction of a JWT's `exp` claim as epoch milliseconds. + * Returns null when the token is not a decodable JWT or carries no numeric exp. + */ +export const getJwtExpiryMs = (token: string): number | null => { + try { + const payload = JSON.parse(decodeJwtPayload(token)) as { exp?: unknown }; + return typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp * 1000 : null; + } catch { + return null; + } +}; diff --git a/src/lib/auth/types.tsx b/src/lib/auth/types.tsx index 140c86ac..109141c6 100644 --- a/src/lib/auth/types.tsx +++ b/src/lib/auth/types.tsx @@ -58,7 +58,6 @@ export interface AuthState { ssoLogin: (credentials: SsoLoginCredentials) => Promise; logout: () => Promise; refreshAccessToken: () => Promise; - hydrate: () => void; isFirstTime: boolean; isAuthenticated: () => boolean; setIsOnboarding: () => void; diff --git a/src/lib/hooks/__tests__/use-keep-alive.test.tsx b/src/lib/hooks/__tests__/use-keep-alive.test.tsx new file mode 100644 index 00000000..43ec2951 --- /dev/null +++ b/src/lib/hooks/__tests__/use-keep-alive.test.tsx @@ -0,0 +1,122 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; + +import { logger } from '@/lib/logging'; + +import { loadKeepAliveState, useKeepAlive } from '../use-keep-alive'; +import { storage } from '../../storage'; + +jest.mock('expo-keep-awake', () => ({ + activateKeepAwakeAsync: jest.fn(), + deactivateKeepAwake: jest.fn(), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('react-native-mmkv', () => ({ + useMMKVBoolean: jest.fn(() => [false, jest.fn()]), + MMKV: jest.fn().mockImplementation(() => ({ + getBoolean: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + })), +})); + +jest.mock('../../storage', () => ({ + storage: { + getBoolean: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})); + +const mockActivate = activateKeepAwakeAsync as jest.MockedFunction; +const mockDeactivate = deactivateKeepAwake as jest.MockedFunction; +const mockStorage = storage as jest.Mocked; + +describe('useKeepAlive', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActivate.mockResolvedValue(undefined as never); + }); + + it('activates keep awake when enabled', async () => { + const { result } = renderHook(() => useKeepAlive()); + + await act(async () => { + await result.current.setKeepAliveEnabled(true); + }); + + expect(mockActivate).toHaveBeenCalledWith('settings'); + }); + + it('deactivates keep awake when disabled', async () => { + const { result } = renderHook(() => useKeepAlive()); + + await act(async () => { + await result.current.setKeepAliveEnabled(false); + }); + + expect(mockDeactivate).toHaveBeenCalledWith('settings'); + }); + + it('reports a failure through the logger rather than console', async () => { + const failure = new Error('keep awake unavailable'); + mockActivate.mockRejectedValue(failure); + + const { result } = renderHook(() => useKeepAlive()); + + await act(async () => { + await result.current.setKeepAliveEnabled(true); + }); + + expect(logger.error).toHaveBeenCalledWith({ + message: 'Failed to update keep alive state', + context: { error: failure, enabled: true }, + }); + }); +}); + +describe('loadKeepAliveState', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActivate.mockResolvedValue(undefined as never); + }); + + it('activates keep awake when the persisted flag is set', async () => { + mockStorage.getBoolean.mockReturnValue(true); + + await loadKeepAliveState(); + + expect(mockActivate).toHaveBeenCalledWith('settings'); + }); + + it('does nothing when the persisted flag is unset', async () => { + mockStorage.getBoolean.mockReturnValue(false); + + await loadKeepAliveState(); + + expect(mockActivate).not.toHaveBeenCalled(); + }); + + it('reports a startup failure through the logger rather than console', async () => { + const failure = new Error('storage unavailable'); + mockStorage.getBoolean.mockImplementation(() => { + throw failure; + }); + + await loadKeepAliveState(); + + expect(logger.error).toHaveBeenCalledWith({ + message: 'Failed to load keep alive state on startup', + context: { error: failure }, + }); + }); +}); diff --git a/src/lib/hooks/use-keep-alive.tsx b/src/lib/hooks/use-keep-alive.tsx index a643bb18..de5388b7 100644 --- a/src/lib/hooks/use-keep-alive.tsx +++ b/src/lib/hooks/use-keep-alive.tsx @@ -2,6 +2,8 @@ import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; import React from 'react'; import { useMMKVBoolean } from 'react-native-mmkv'; +import { logger } from '@/lib/logging'; + import { storage } from '../storage'; const KEEP_ALIVE_ENABLED = 'KEEP_ALIVE_ENABLED'; @@ -24,7 +26,10 @@ export const useKeepAlive = () => { } _setKeepAliveEnabled(enabled); } catch (error) { - console.error('Failed to update keep alive state:', error); + logger.error({ + message: 'Failed to update keep alive state', + context: { error, enabled }, + }); } }, [_setKeepAliveEnabled] @@ -42,6 +47,9 @@ export const loadKeepAliveState = async () => { await activateKeepAwakeAsync('settings'); } } catch (error) { - console.error('Failed to load keep alive state on startup:', error); + logger.error({ + message: 'Failed to load keep alive state on startup', + context: { error }, + }); } }; diff --git a/src/lib/map-camera.ts b/src/lib/map-camera.ts new file mode 100644 index 00000000..8b5c1854 --- /dev/null +++ b/src/lib/map-camera.ts @@ -0,0 +1,141 @@ +/** + * Pure helpers for the map's follow-the-unit camera and location indicator. + * + * The camera zoom scales with how fast the unit is moving so the view works for + * both crews on foot (walking pace, tight zoom) and vehicles (highway speed, + * wide zoom) — the same way turn-by-turn navigation apps frame the road ahead. + */ + +/** Speed (m/s) → zoom level anchor points, interpolated linearly between them. */ +const SPEED_ZOOM_STOPS: readonly (readonly [number, number])[] = [ + [0, 17], // stationary — tight view for crews on foot + [1.5, 16.5], // walking pace (~5 km/h) + [4, 16], // jogging / vehicle crawling + [9, 15], // ~32 km/h urban response + [18, 14], // ~65 km/h + [28, 13.25], // ~100 km/h highway + [40, 12.5], // upper bound +]; + +/** + * Zoom level for a given ground speed in meters/second. Piecewise-linear over + * SPEED_ZOOM_STOPS and clamped to the outer stops, so walking speeds resolve to + * street-level zoom and highway speeds pull the camera out for lookahead. + */ +export function zoomForSpeed(speedMps: number): number { + const speed = normalizeSpeed(speedMps); + + const [firstSpeed, firstZoom] = SPEED_ZOOM_STOPS[0]; + if (speed <= firstSpeed) return firstZoom; + + for (let i = 1; i < SPEED_ZOOM_STOPS.length; i++) { + const [stopSpeed, stopZoom] = SPEED_ZOOM_STOPS[i]; + if (speed <= stopSpeed) { + const [prevSpeed, prevZoom] = SPEED_ZOOM_STOPS[i - 1]; + const ratio = (speed - prevSpeed) / (stopSpeed - prevSpeed); + return prevZoom + (stopZoom - prevZoom) * ratio; + } + } + + return SPEED_ZOOM_STOPS[SPEED_ZOOM_STOPS.length - 1][1]; +} + +/** + * GPS heading is degrees clockwise from north, but platforms report "no fix" + * as -1 (iOS) or null. Returns a value in [0, 360) or null when there is no + * usable heading. + */ +export function normalizeHeading(heading: number | null | undefined): number | null { + if (heading == null || !Number.isFinite(heading) || heading < 0) { + return null; + } + return heading % 360; +} + +/** GPS speed is m/s, with "no fix" reported as -1 (iOS) or null. Clamps to >= 0. */ +export function normalizeSpeed(speed: number | null | undefined): number { + if (speed == null || !Number.isFinite(speed) || speed < 0) { + return 0; + } + return speed; +} + +/** + * Exponential moving average over successive speed fixes. GPS speed is noisy — + * smoothing keeps the camera from pumping zoom in and out on every fix. + */ +export function smoothSpeed(previous: number | null, next: number, alpha: number = 0.4): number { + const target = normalizeSpeed(next); + if (previous == null) return target; + return previous + (target - previous) * alpha; +} + +/** + * Apply hysteresis to a proposed zoom change: keep the current zoom unless the + * new target differs by at least `threshold` levels. Prevents oscillation when + * a speed hovers around a stop boundary. + */ +export function applyZoomHysteresis(currentZoom: number | null, targetZoom: number, threshold: number = 0.25): number { + if (currentZoom == null) return targetZoom; + return Math.abs(targetZoom - currentZoom) >= threshold ? targetZoom : currentZoom; +} + +/** Camera pitch while the unit is moving (navigation-style tilt behind the unit). */ +export const FOLLOW_PITCH_MOVING = 45; +/** Smoothed speed (m/s) above which the camera tilts up behind the unit. */ +export const PITCH_TILT_UP_SPEED_MPS = 1.5; +/** Smoothed speed (m/s) below which the camera returns to a top-down view. */ +export const PITCH_TILT_DOWN_SPEED_MPS = 0.7; + +/** + * Pitch for a given smoothed ground speed, with hysteresis so the camera + * doesn't flip between tilted and top-down when speed hovers around the + * moving/stationary boundary (mirrors the zoom hysteresis above): tilt up + * above PITCH_TILT_UP_SPEED_MPS, tilt down below PITCH_TILT_DOWN_SPEED_MPS, + * and hold the current pitch in between. + */ +export function applyPitchHysteresis(currentPitch: number | null, speedMps: number): number { + const speed = normalizeSpeed(speedMps); + if (speed >= PITCH_TILT_UP_SPEED_MPS) return FOLLOW_PITCH_MOVING; + if (speed <= PITCH_TILT_DOWN_SPEED_MPS) return 0; + return currentPitch ?? 0; +} + +/** Wrap a longitude into [-180, 180). */ +export function wrapLongitude(longitude: number): number { + return ((((longitude + 180) % 360) + 360) % 360) - 180; +} + +/** + * GeoJSON circle polygon around a coordinate, radius in meters. Corrects + * longitude spacing by latitude so the circle stays round away from the + * equator (one degree of longitude shrinks with cos(latitude)). + */ +export function createCirclePolygon(longitude: number, latitude: number, radiusMeters: number, points: number = 64): GeoJSON.Feature { + // Normalize an out-of-range center into [-180, 180). Vertex offsets are then + // applied WITHOUT re-wrapping: a ring that pokes just past ±180 must stay + // contiguous for Mapbox to render it across the antimeridian (re-wrapping + // individual vertices would flip them to the far side of the world). + const centerLon = wrapLongitude(longitude); + const latRadiusDeg = radiusMeters / 110574; + const cosLat = Math.cos((latitude * Math.PI) / 180); + // Near the poles cos(lat) approaches zero; floor it so the division stays finite. + const lonRadiusDeg = radiusMeters / (111320 * Math.max(Math.abs(cosLat), 0.01)); + + const coords: number[][] = []; + for (let i = 0; i < points; i++) { + const angle = (i / points) * 2 * Math.PI; + // Clamp latitude so a large radius near a pole stays a valid coordinate. + const lat = Math.min(90, Math.max(-90, latitude + latRadiusDeg * Math.sin(angle))); + coords.push([centerLon + lonRadiusDeg * Math.cos(angle), lat]); + } + // Close the ring exactly — sin/cos at 2π carry float error, and GeoJSON + // requires the first and last positions to be identical. + coords.push([...coords[0]]); + + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [coords] }, + }; +} diff --git a/src/services/__tests__/aptabase.service.test.ts b/src/services/__tests__/aptabase.service.test.ts new file mode 100644 index 00000000..3e9162e6 --- /dev/null +++ b/src/services/__tests__/aptabase.service.test.ts @@ -0,0 +1,82 @@ +import { aptabaseService, countlyService } from '../aptabase.service'; + +jest.mock('countly-sdk-react-native-bridge', () => ({ + __esModule: true, + default: { + events: { + recordEvent: jest.fn(), + }, + }, +})); + +jest.mock('../../lib/logging', () => ({ + logger: { + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +describe('aptabase service', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useFakeTimers(); + countlyService.reset(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('keeps the legacy aptabaseService alias pointing at the same instance', () => { + expect(aptabaseService).toBe(countlyService); + }); + + it('resets the retry counter after a successful event', () => { + const Countly = require('countly-sdk-react-native-bridge').default; + + Countly.events.recordEvent.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + countlyService.trackEvent('test_event'); + expect(countlyService.getStatus().retryCount).toBe(1); + + Countly.events.recordEvent.mockImplementation(() => undefined); + countlyService.trackEvent('test_event'); + + expect(countlyService.getStatus().retryCount).toBe(0); + }); + + it('does not disable analytics for isolated failures separated by successes', () => { + const Countly = require('countly-sdk-react-native-bridge').default; + + for (let i = 0; i < 5; i += 1) { + Countly.events.recordEvent.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + countlyService.trackEvent('test_event'); + + Countly.events.recordEvent.mockImplementation(() => undefined); + countlyService.trackEvent('test_event'); + } + + // Without the reset, two lifetime errors would have disabled analytics for + // the full 10-minute window. + expect(countlyService.isAnalyticsDisabled()).toBe(false); + }); + + it('still disables analytics after consecutive failures', () => { + const Countly = require('countly-sdk-react-native-bridge').default; + Countly.events.recordEvent.mockImplementation(() => { + throw new Error('Network error'); + }); + + countlyService.trackEvent('test_event'); + countlyService.trackEvent('test_event'); + + expect(countlyService.isAnalyticsDisabled()).toBe(true); + }); +}); diff --git a/src/services/__tests__/bluetooth-audio.service.test.ts b/src/services/__tests__/bluetooth-audio.service.test.ts index 10c6ec67..fc491c2d 100644 --- a/src/services/__tests__/bluetooth-audio.service.test.ts +++ b/src/services/__tests__/bluetooth-audio.service.test.ts @@ -95,8 +95,10 @@ describe('BluetoothAudioService Refactoring', () => { jest.clearAllMocks(); }); - afterEach(() => { - bluetoothAudioService.destroy(); + afterEach(async () => { + // destroy() awaits its stopScanning/disconnectDevice teardown before + // resetting state, so it must be awaited. + await bluetoothAudioService.destroy(); }); it('should be defined and accessible', () => { @@ -131,25 +133,25 @@ describe('BluetoothAudioService Refactoring', () => { it('should track hasAttemptedPreferredDeviceConnection flag for single-call semantics', () => { const service = bluetoothAudioService as any; - + // Initially should be false expect(service.hasAttemptedPreferredDeviceConnection).toBe(false); - + // Can be set to true (simulating attempt) service.hasAttemptedPreferredDeviceConnection = true; expect(service.hasAttemptedPreferredDeviceConnection).toBe(true); }); - it('should reset flags on destroy method', () => { + it('should reset flags on destroy method', async () => { const service = bluetoothAudioService as any; - + // Set flags to true service.hasAttemptedPreferredDeviceConnection = true; service.isInitialized = true; - - // Call destroy - bluetoothAudioService.destroy(); - + + // Call destroy — it awaits its async teardown before resetting flags + await bluetoothAudioService.destroy(); + // Verify flags are reset for single-call logic expect(service.hasAttemptedPreferredDeviceConnection).toBe(false); expect(service.isInitialized).toBe(false); @@ -157,13 +159,13 @@ describe('BluetoothAudioService Refactoring', () => { it('should support iOS state change handling through attemptReconnectToPreferredDevice', () => { const service = bluetoothAudioService as any; - + // Set up scenario: connection was previously attempted service.hasAttemptedPreferredDeviceConnection = true; - + // Verify the method exists for iOS poweredOn state handling expect(typeof service.attemptReconnectToPreferredDevice).toBe('function'); - + // This method should be called when Bluetooth state changes to poweredOn on iOS // It resets the flag and attempts preferred device connection again }); @@ -172,30 +174,30 @@ describe('BluetoothAudioService Refactoring', () => { describe('Single-Call Logic Validation', () => { it('should implement single-call semantics for preferred device connection', () => { const service = bluetoothAudioService as any; - + // Simulate first call - should set flag service.hasAttemptedPreferredDeviceConnection = false; // In actual implementation, attemptPreferredDeviceConnection would set this to true - + // Simulate second call - should not execute due to flag expect(service.hasAttemptedPreferredDeviceConnection).toBe(false); - + // After first attempt service.hasAttemptedPreferredDeviceConnection = true; expect(service.hasAttemptedPreferredDeviceConnection).toBe(true); - + // Second attempt should be blocked by this flag }); - it('should allow re-attempting connection after destroy', () => { + it('should allow re-attempting connection after destroy', async () => { const service = bluetoothAudioService as any; - + // Simulate connection attempt service.hasAttemptedPreferredDeviceConnection = true; - + // Destroy service (resets flags) - bluetoothAudioService.destroy(); - + await bluetoothAudioService.destroy(); + // Flag should be reset, allowing new attempts expect(service.hasAttemptedPreferredDeviceConnection).toBe(false); }); @@ -314,4 +316,107 @@ describe('BluetoothAudioService Refactoring', () => { expect(mockHandleButtonEvent).toHaveBeenCalledTimes(1); }); }); + + describe('Read polling only covers characteristics whose notifications are not working', () => { + it('should mark a characteristic confirmed when a GATT notification arrives', () => { + const service = bluetoothAudioService as any; + service.connectedDevice = { id: 'device-1', name: 'PTT' }; + service.monitoredReadCharacteristics = [{ serviceUuid: 'service-1', characteristicUuid: 'characteristic-1', lastHexValue: null, notificationConfirmed: false }]; + + service.handleCharacteristicValueUpdate({ + peripheral: 'device-1', + service: 'service-1', + characteristic: 'characteristic-1', + value: [0x01], + }); + + expect(service.monitoredReadCharacteristics[0].notificationConfirmed).toBe(true); + }); + + it('should stop reading a characteristic once its notifications are proven to work', async () => { + const service = bluetoothAudioService as any; + const bleManagerMock = require('react-native-ble-manager').default; + + service.connectedDevice = { id: 'device-1', name: 'PTT' }; + service.monitoredReadCharacteristics = [ + { serviceUuid: 'confirmed-svc', characteristicUuid: 'confirmed-chr', lastHexValue: null, notificationConfirmed: true }, + { serviceUuid: 'silent-svc', characteristicUuid: 'silent-chr', lastHexValue: null, notificationConfirmed: false }, + ]; + + bleManagerMock.read = jest.fn().mockResolvedValue([0x00]); + + await service.pollReadCharacteristics('device-1'); + + // Only the characteristic that has never delivered a notification is polled. + expect(bleManagerMock.read).toHaveBeenCalledTimes(1); + expect(bleManagerMock.read).toHaveBeenCalledWith('device-1', 'silent-svc', 'silent-chr'); + }); + + it('should keep polling characteristics that genuinely never notify (screen-off PTT)', async () => { + const service = bluetoothAudioService as any; + const bleManagerMock = require('react-native-ble-manager').default; + + service.connectedDevice = { id: 'device-1', name: 'PTT' }; + service.monitoredReadCharacteristics = [{ serviceUuid: 'silent-svc', characteristicUuid: 'silent-chr', lastHexValue: null, notificationConfirmed: false }]; + + bleManagerMock.read = jest.fn().mockResolvedValue([0x00]); + + await service.pollReadCharacteristics('device-1'); + await service.pollReadCharacteristics('device-1'); + + // Background PTT depends on this fallback, so it must not be paused. + expect(bleManagerMock.read).toHaveBeenCalledTimes(2); + }); + + it('should register new polling entries as unconfirmed so a re-subscribe resumes polling', () => { + const service = bluetoothAudioService as any; + service.monitoredReadCharacteristics = []; + + service.registerReadPollingCharacteristic('service-1', 'characteristic-1'); + + expect(service.monitoredReadCharacteristics[0]).toMatchObject({ + serviceUuid: 'service-1', + characteristicUuid: 'characteristic-1', + notificationConfirmed: false, + }); + }); + }); + + describe('connectToDevice concurrency guard', () => { + it('should ignore a duplicate connect while one is already in flight', async () => { + const service = bluetoothAudioService as any; + const bleManagerMock = require('react-native-ble-manager').default; + + bleManagerMock.connect = jest.fn().mockResolvedValue(undefined); + service.isConnecting = true; + + try { + await bluetoothAudioService.connectToDevice('device-1'); + // The dead connectionTimeout guard let overlapping discovery events start + // concurrent connects; the real flag must short-circuit instead. + expect(bleManagerMock.connect).not.toHaveBeenCalled(); + } finally { + service.isConnecting = false; + } + }); + + it('should clear the connecting flag when a connect fails', async () => { + jest.useFakeTimers(); + const service = bluetoothAudioService as any; + const bleManagerMock = require('react-native-ble-manager').default; + + service.isConnecting = false; + bleManagerMock.stopScan = jest.fn().mockResolvedValue(undefined); + bleManagerMock.connect = jest.fn().mockRejectedValue(new Error('connect failed')); + + const attempt = bluetoothAudioService.connectToDevice('device-1'); + const assertion = expect(attempt).rejects.toThrow('connect failed'); + + await jest.advanceTimersByTimeAsync(600); + await assertion; + + expect(service.isConnecting).toBe(false); + jest.useRealTimers(); + }); + }); }); diff --git a/src/services/__tests__/countly.service.test.ts b/src/services/__tests__/countly.service.test.ts index c362d321..cd674172 100644 --- a/src/services/__tests__/countly.service.test.ts +++ b/src/services/__tests__/countly.service.test.ts @@ -39,9 +39,9 @@ describe('CountlyService', () => { it('should track events', () => { const Countly = require('countly-sdk-react-native-bridge').default; - + countlyService.trackEvent('test_event', { prop1: 'value1' }); - + expect(Countly.events.recordEvent).toHaveBeenCalledWith('test_event', { prop1: 'value1' }, 1); expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Tracking Countly event', @@ -55,13 +55,13 @@ describe('CountlyService', () => { it('should not track events when disabled', () => { const Countly = require('countly-sdk-react-native-bridge').default; - + // Manually disable the service countlyService.reset(); countlyService['isDisabled'] = true; - + countlyService.trackEvent('test_event', { prop1: 'value1' }); - + expect(Countly.events.recordEvent).not.toHaveBeenCalled(); expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Analytics event skipped - service is disabled', @@ -93,6 +93,43 @@ describe('CountlyService', () => { }); }); + it('should reset the retry counter after a successful event', () => { + const Countly = require('countly-sdk-react-native-bridge').default; + countlyService.reset(); + + // One failure... + Countly.events.recordEvent.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + countlyService.trackEvent('test_event'); + expect(countlyService.getStatus().retryCount).toBe(1); + + // ...then a success proves the SDK is healthy again. + Countly.events.recordEvent.mockImplementation(() => undefined); + countlyService.trackEvent('test_event'); + + expect(countlyService.getStatus().retryCount).toBe(0); + }); + + it('should not disable analytics for isolated failures separated by successes', () => { + const Countly = require('countly-sdk-react-native-bridge').default; + countlyService.reset(); + + for (let i = 0; i < 5; i += 1) { + Countly.events.recordEvent.mockImplementationOnce(() => { + throw new Error('Network error'); + }); + countlyService.trackEvent('test_event'); + + Countly.events.recordEvent.mockImplementation(() => undefined); + countlyService.trackEvent('test_event'); + } + + // Without the reset, two lifetime errors would have disabled analytics + // for the full 10-minute window. + expect(countlyService.isAnalyticsDisabled()).toBe(false); + }); + it('should disable after max retries', () => { const Countly = require('countly-sdk-react-native-bridge').default; Countly.events.recordEvent.mockImplementation(() => { @@ -147,7 +184,7 @@ describe('CountlyService', () => { describe('service management', () => { it('should provide status information', () => { const status = countlyService.getStatus(); - + expect(status).toEqual({ retryCount: expect.any(Number), isDisabled: expect.any(Boolean), @@ -165,12 +202,12 @@ describe('CountlyService', () => { // Cause some errors first countlyService.trackEvent('test_event'); countlyService.trackEvent('test_event'); - + expect(countlyService.isAnalyticsDisabled()).toBe(true); - + // Reset should clear the state countlyService.reset(); - + expect(countlyService.isAnalyticsDisabled()).toBe(false); expect(countlyService.getStatus().retryCount).toBe(0); }); diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index 9349537b..8fe11f9e 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -211,6 +211,66 @@ describe('LocationService', () => { (locationService as any).locationSubscription = null; (locationService as any).backgroundSubscription = null; (locationService as any).isBackgroundGeolocationEnabled = false; + (locationService as any).startPromise = null; + (locationService as any).startBackgroundPromise = null; + }); + + describe('Concurrent start/stop safety', () => { + it('should create only one foreground watcher when starts overlap', async () => { + mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); + + // Two callers race before the first watcher is assigned — the pre-fix + // guard let both through and leaked a duplicate watcher. + const first = locationService.startLocationUpdates(); + const second = locationService.startLocationUpdates(); + await Promise.all([first, second]); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); + }); + + it('should not leave an orphaned watcher when stop interleaves with a start', async () => { + mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); + + const start = locationService.startLocationUpdates(); + // Stop issued while the watcher is still being created. + const stop = locationService.stopLocationUpdates(); + await Promise.all([start, stop]); + + // The stop waited for the start, so the watcher it created was removed. + expect(mockLocationSubscription.remove).toHaveBeenCalled(); + expect((locationService as any).locationSubscription).toBeNull(); + }); + + it('should create only one background watcher when starts overlap', async () => { + (locationService as any).isBackgroundGeolocationEnabled = true; + mockTaskManager.isTaskRegisteredAsync.mockResolvedValue(false); + mockLocation.watchPositionAsync.mockResolvedValue(mockLocationSubscription); + + const first = locationService.startBackgroundUpdates(); + const second = locationService.startBackgroundUpdates(); + await Promise.all([first, second]); + + expect(mockLocation.watchPositionAsync).toHaveBeenCalledTimes(1); + }); + }); + + describe('App state handler resilience', () => { + it('should log a warning instead of rejecting when a backgrounded start fails', async () => { + (locationService as any).isBackgroundGeolocationEnabled = true; + mockTaskManager.isTaskRegisteredAsync.mockRejectedValue(new Error('permission revoked')); + + // The handler is registered with AppState and its result is never awaited, + // so a throw here would surface as an unhandled rejection. + await expect((locationService as any).handleAppStateChange('background')).resolves.toBeUndefined(); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to handle location app state change', + }) + ); + }); }); describe('Singleton Pattern', () => { @@ -821,7 +881,9 @@ describe('LocationService', () => { await sendLocation(); // Counter restarted, so this is a first rejection again, not a third. - expect(mockLogger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Backing off location updates after server rejection', context: expect.objectContaining({ consecutiveRejections: 1, backoffMs: 30_000 }) })); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Backing off location updates after server rejection', context: expect.objectContaining({ consecutiveRejections: 1, backoffMs: 30_000 }) }) + ); }); it('does not back off on server errors, which are transient', async () => { diff --git a/src/services/__tests__/offline-event-manager.service.test.ts b/src/services/__tests__/offline-event-manager.service.test.ts index e7781b42..eee43f94 100644 --- a/src/services/__tests__/offline-event-manager.service.test.ts +++ b/src/services/__tests__/offline-event-manager.service.test.ts @@ -37,6 +37,7 @@ jest.mock('@/stores/offline-queue/store', () => ({ useOfflineQueueStore: { getState: jest.fn(), }, + setOfflineQueueActivityListener: jest.fn(), })); // Mock logger @@ -89,6 +90,22 @@ const mockAppState = AppState as jest.Mocked; describe('OfflineEventManager', () => { let mockStoreState: any; + // The processing timer only runs while the queue has something to do, so any + // test that expects a timer has to give the queue work first. + const seedPendingWork = (): void => { + const event = { + id: 'seeded-event', + type: QueuedEventType.UNIT_STATUS, + status: QueuedEventStatus.PENDING, + data: { unitId: 'unit-1', statusType: 'available', timestamp: '2023-01-01T00:00:00Z', timestampUtc: 'Sun, 01 Jan 2023 00:00:00 GMT' }, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + }; + mockStoreState.getPendingEvents.mockReturnValue([event]); + mockStoreState.queuedEvents = [event]; + }; + beforeAll(() => { // Use fake timers for the entire test suite jest.useFakeTimers(); @@ -103,7 +120,7 @@ describe('OfflineEventManager', () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); - + mockStoreState = { isConnected: true, isNetworkReachable: true, @@ -112,6 +129,8 @@ describe('OfflineEventManager', () => { removeEvent: jest.fn(), getPendingEvents: jest.fn().mockReturnValue([]), getFailedEvents: jest.fn().mockReturnValue([]), + pruneFailedEvents: jest.fn(), + queuedEvents: [], initializeNetworkListener: jest.fn(), retryAllFailedEvents: jest.fn(), clearCompletedEvents: jest.fn(), @@ -121,7 +140,7 @@ describe('OfflineEventManager', () => { }; mockUseOfflineQueueStore.getState.mockReturnValue(mockStoreState); - + // Setup AppState mock mockAppState.addEventListener.mockReturnValue({ remove: jest.fn() }); }); @@ -138,14 +157,7 @@ describe('OfflineEventManager', () => { describe('queueUnitStatusEvent', () => { it('should queue a unit status event', () => { - const eventId = offlineEventManager.queueUnitStatusEvent( - 'unit-1', - 'available', - 'Test note', - 'call-1', - 2, - [{ roleId: 'role-1', userId: 'user-1' }] - ); + const eventId = offlineEventManager.queueUnitStatusEvent('unit-1', 'available', 'Test note', 'call-1', 2, [{ roleId: 'role-1', userId: 'user-1' }]); expect(eventId).toBe('test-event-id'); expect(mockStoreState.addEvent).toHaveBeenCalledWith( @@ -183,14 +195,7 @@ describe('OfflineEventManager', () => { describe('queueLocationUpdateEvent', () => { it('should queue a location update event', () => { - const eventId = offlineEventManager.queueLocationUpdateEvent( - 'unit-1', - 40.7128, - -74.0060, - 10, - 45, - 25 - ); + const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006, 10, 45, 25); expect(eventId).toBe('test-event-id'); expect(mockStoreState.addEvent).toHaveBeenCalledWith( @@ -198,7 +203,7 @@ describe('OfflineEventManager', () => { expect.objectContaining({ unitId: 'unit-1', latitude: 40.7128, - longitude: -74.0060, + longitude: -74.006, accuracy: 10, heading: 45, speed: 25, @@ -208,7 +213,7 @@ describe('OfflineEventManager', () => { }); it('should queue location update event without optional parameters', () => { - const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.0060); + const eventId = offlineEventManager.queueLocationUpdateEvent('unit-1', 40.7128, -74.006); expect(eventId).toBe('test-event-id'); expect(mockStoreState.addEvent).toHaveBeenCalledWith( @@ -216,7 +221,7 @@ describe('OfflineEventManager', () => { expect.objectContaining({ unitId: 'unit-1', latitude: 40.7128, - longitude: -74.0060, + longitude: -74.006, accuracy: undefined, heading: undefined, speed: undefined, @@ -227,15 +232,7 @@ describe('OfflineEventManager', () => { describe('queueCallImageUploadEvent', () => { it('should queue a call image upload event', () => { - const eventId = offlineEventManager.queueCallImageUploadEvent( - 'call-1', - 'user-1', - 'Test note', - 'image.jpg', - '/path/to/image.jpg', - 40.7128, - -74.0060 - ); + const eventId = offlineEventManager.queueCallImageUploadEvent('call-1', 'user-1', 'Test note', 'image.jpg', '/path/to/image.jpg', 40.7128, -74.006); expect(eventId).toBe('test-event-id'); expect(mockStoreState.addEvent).toHaveBeenCalledWith( @@ -247,19 +244,13 @@ describe('OfflineEventManager', () => { name: 'image.jpg', filePath: '/path/to/image.jpg', latitude: 40.7128, - longitude: -74.0060, + longitude: -74.006, }) ); }); it('should queue call image upload event without optional parameters', () => { - const eventId = offlineEventManager.queueCallImageUploadEvent( - 'call-1', - 'user-1', - 'Test note', - 'image.jpg', - '/path/to/image.jpg' - ); + const eventId = offlineEventManager.queueCallImageUploadEvent('call-1', 'user-1', 'Test note', 'image.jpg', '/path/to/image.jpg'); expect(eventId).toBe('test-event-id'); expect(mockStoreState.addEvent).toHaveBeenCalledWith( @@ -321,32 +312,87 @@ describe('OfflineEventManager', () => { }); describe('startProcessing', () => { - it('should start processing interval', () => { + it('should start processing interval when the queue has work', () => { + seedPendingWork(); const setIntervalSpy = jest.spyOn(global, 'setInterval'); - + offlineEventManager.startProcessing(); // Verify setInterval was called expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000); - + // Verify immediate processing call expect(mockStoreState.getPendingEvents).toHaveBeenCalled(); }); it('should not start multiple intervals', () => { + seedPendingWork(); const setIntervalSpy = jest.spyOn(global, 'setInterval'); - + offlineEventManager.startProcessing(); offlineEventManager.startProcessing(); expect(setIntervalSpy).toHaveBeenCalledTimes(1); }); + + it('should not arm a timer while the queue is empty', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + offlineEventManager.startProcessing(); + + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); + + it('should stop the timer once the queue drains', async () => { + // Arm the timer while offline so the initial run returns before it starts + // draining (which would otherwise still be in flight below). + seedPendingWork(); + mockStoreState.isConnected = false; + mockStoreState.isNetworkReachable = false; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + offlineEventManager.startProcessing(); + clearIntervalSpy.mockClear(); + + // Queue drains — the next tick has nothing to do and must not keep ticking. + mockStoreState.isConnected = true; + mockStoreState.isNetworkReachable = true; + mockStoreState.getPendingEvents.mockReturnValue([]); + mockStoreState.queuedEvents = []; + + await (offlineEventManager as any).processQueuedEvents(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + + it('should evict permanently failed events while processing', async () => { + seedPendingWork(); + + await (offlineEventManager as any).processQueuedEvents(); + + expect(mockStoreState.pruneFailedEvents).toHaveBeenCalled(); + }); + + it('should keep the timer armed for events waiting on a retry backoff', async () => { + // Not returned by getPendingEvents (backoff has not elapsed) but still live. + mockStoreState.getPendingEvents.mockReturnValue([]); + mockStoreState.queuedEvents = [{ id: 'backoff', status: QueuedEventStatus.FAILED, retryCount: 1, maxRetries: 3, nextRetryAt: Date.now() + 60000 }]; + + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + offlineEventManager.startProcessing(); + clearIntervalSpy.mockClear(); + + await (offlineEventManager as any).processQueuedEvents(); + + expect(clearIntervalSpy).not.toHaveBeenCalled(); + }); }); describe('stopProcessing', () => { it('should stop processing interval', () => { + seedPendingWork(); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - + offlineEventManager.startProcessing(); offlineEventManager.stopProcessing(); @@ -362,18 +408,18 @@ describe('OfflineEventManager', () => { }); it('should set up processing interval but skip processing when offline', () => { + seedPendingWork(); mockStoreState.isConnected = false; mockStoreState.isNetworkReachable = false; - + const setIntervalSpy = jest.spyOn(global, 'setInterval'); offlineEventManager.startProcessing(); - // The interval should still be set up, even if offline + // The interval stays armed while offline so the queued work drains as soon + // as connectivity returns; processing itself returns early. expect(setIntervalSpy).toHaveBeenCalled(); - - // When offline, processQueuedEvents will return early and not call getPendingEvents - // So we just verify the interval was set up + expect(mockStoreState.updateEventStatus).not.toHaveBeenCalled(); }); it('should set up processing interval when online', () => { @@ -393,6 +439,7 @@ describe('OfflineEventManager', () => { }; mockStoreState.getPendingEvents.mockReturnValue([mockEvent]); + mockStoreState.queuedEvents = [mockEvent]; const setIntervalSpy = jest.spyOn(global, 'setInterval'); // Trigger processing @@ -400,7 +447,7 @@ describe('OfflineEventManager', () => { // The interval should be set up expect(setIntervalSpy).toHaveBeenCalled(); - + // Verify that getPendingEvents is called immediately when online expect(mockStoreState.getPendingEvents).toHaveBeenCalled(); }); @@ -411,14 +458,14 @@ describe('OfflineEventManager', () => { // The AppState listener should have been set up when the module was imported // Even if the mock wasn't capturing it initially, we can test the behavior // by directly calling the handler method that would be triggered - + // Create a spy to verify the method calls const startProcessingSpy = jest.spyOn(offlineEventManager, 'startProcessing'); - + // Since we can't easily test the private method directly, let's test via initialize // which calls handleAppStateChange with current state offlineEventManager.initialize(); - + // The initialize method calls handleAppStateChange with AppState.currentState ('active') // which should trigger startProcessing expect(startProcessingSpy).toHaveBeenCalled(); @@ -430,7 +477,7 @@ describe('OfflineEventManager', () => { expect(() => { offlineEventManager.initialize(); }).not.toThrow(); - + // Verify the store initialization was called expect(mockStoreState.initializeNetworkListener).toHaveBeenCalled(); }); diff --git a/src/services/__tests__/signalr.service.reconnect-fix.test.ts b/src/services/__tests__/signalr.service.reconnect-fix.test.ts index dbfdee74..2012fc2a 100644 --- a/src/services/__tests__/signalr.service.reconnect-fix.test.ts +++ b/src/services/__tests__/signalr.service.reconnect-fix.test.ts @@ -96,13 +96,13 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should allow direct connection attempts when hub is in reconnecting state', async () => { // Set hub to reconnecting state (signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING); - + // Verify hub is in reconnecting state expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true); - + // Attempt direct connection - should not be blocked await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Should have attempted the connection expect(mockHubConnectionBuilder).toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ @@ -113,10 +113,10 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should prevent duplicate direct connections', async () => { // Set hub to direct-connecting state (signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING); - + // Attempt another direct connection - should be blocked await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Should not have attempted the connection expect(mockHubConnectionBuilder).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith({ @@ -127,10 +127,10 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should clean up direct-connecting state on successful connection', async () => { // Start with idle state expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false); - + // Attempt connection await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Should be back to idle state after successful connection expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false); expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined(); @@ -139,17 +139,17 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should clean up direct-connecting state on failed connection', async () => { // Mock connection failure mockStart.mockRejectedValueOnce(new Error('Connection failed')); - + // Start with idle state expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false); - + // Attempt connection (should fail) await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow('Connection failed'); - + // Should be back to idle state after failed connection expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(false); expect((signalRService as any).hubStates.get(mockConfig.name)).toBeUndefined(); - + // Reset mock for future tests mockStart.mockResolvedValue(undefined); }); @@ -157,14 +157,14 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should maintain backward compatibility with legacy reconnectingHubs set', async () => { // Set hub to reconnecting state (signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING); - + // Legacy reconnectingHubs set should also be updated expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(true); expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true); - + // Clear state (signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE); - + // Legacy set should be cleared too expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false); expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false); @@ -177,12 +177,12 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { (signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING); expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(true); expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true); - + // Set to direct-connecting (signalRService as any).setHubState(mockConfig.name, HubConnectingState.DIRECT_CONNECTING); expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false); expect((signalRService as any).isHubConnecting(mockConfig.name)).toBe(true); - + // Set to idle (signalRService as any).setHubState(mockConfig.name, HubConnectingState.IDLE); expect(signalRService.isHubReconnecting(mockConfig.name)).toBe(false); @@ -191,26 +191,75 @@ describe('SignalRService - Reconnect Self-Blocking Fix', () => { it('should properly manage isHubAvailable with new states', () => { const hubName = 'testHub'; - + // Not connected, not connecting expect(signalRService.isHubAvailable(hubName)).toBe(false); - + // Reconnecting (signalRService as any).setHubState(hubName, HubConnectingState.RECONNECTING); expect(signalRService.isHubAvailable(hubName)).toBe(true); - + // Direct connecting (signalRService as any).setHubState(hubName, HubConnectingState.DIRECT_CONNECTING); expect(signalRService.isHubAvailable(hubName)).toBe(true); - + // Add actual connection (signalRService as any).connections.set(hubName, mockConnection); (signalRService as any).setHubState(hubName, HubConnectingState.IDLE); expect(signalRService.isHubAvailable(hubName)).toBe(true); - + // Clean up (signalRService as any).connections.delete(hubName); expect(signalRService.isHubAvailable(hubName)).toBe(false); }); }); + + describe('connectToHub stores a reconnectable config', () => { + it('should store the hub config so handleConnectionClose can reconnect', async () => { + await signalRService.connectToHub({ + name: 'urlHub', + url: 'https://api.example.com/eventing/eventingHub', + methods: ['method1'], + }); + + // Without a stored config handleConnectionClose logs "No stored config + // found" and gives up instead of reconnecting. + const stored = (signalRService as any).hubConfigs.get('urlHub'); + expect(stored).toEqual({ + name: 'urlHub', + eventingUrl: 'https://api.example.com/eventing', + hubName: 'eventingHub', + methods: ['method1'], + }); + }); + + it('should preserve query parameters when deriving the eventing url', async () => { + await signalRService.connectToHub({ + name: 'queryHub', + url: 'https://api.example.com/eventing/eventingHub?tenant=42', + methods: ['method1'], + }); + + const stored = (signalRService as any).hubConfigs.get('queryHub'); + expect(stored.eventingUrl).toBe('https://api.example.com/eventing?tenant=42'); + expect(stored.hubName).toBe('eventingHub'); + }); + + it('should not report a missing config when the connection closes', () => { + (signalRService as any).hubConfigs.set('urlHub', { + name: 'urlHub', + eventingUrl: 'https://api.example.com/eventing', + hubName: 'eventingHub', + methods: ['method1'], + }); + + (signalRService as any).handleConnectionClose('urlHub'); + + expect(mockLogger.error).not.toHaveBeenCalledWith({ + message: 'No stored config found for hub: urlHub, cannot attempt reconnection', + }); + + (signalRService as any).clearReconnectTimer('urlHub'); + }); + }); }); diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts index f180a9a6..f3a31970 100644 --- a/src/services/__tests__/signalr.service.test.ts +++ b/src/services/__tests__/signalr.service.test.ts @@ -13,7 +13,7 @@ jest.mock('@/lib/env', () => ({ // Mock the auth store jest.mock('@/stores/auth/store', () => { const mockRefreshAccessToken = jest.fn().mockResolvedValue(undefined); - const mockGetState = jest.fn(() => ({ + const mockGetState = jest.fn(() => ({ accessToken: 'mock-token', refreshAccessToken: mockRefreshAccessToken, })); @@ -81,7 +81,7 @@ describe('SignalRService', () => { mockRefreshAccessToken.mockResolvedValue(undefined); // Mock auth store - mockGetState.mockReturnValue({ + mockGetState.mockReturnValue({ accessToken: 'mock-token', refreshAccessToken: mockRefreshAccessToken, }); @@ -127,14 +127,12 @@ describe('SignalRService', () => { }); it('should throw error if no access token is available', async () => { - mockGetState.mockReturnValue({ + mockGetState.mockReturnValue({ accessToken: '', refreshAccessToken: mockRefreshAccessToken, }); - await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow( - 'No authentication token available' - ); + await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow('No authentication token available'); }); it('should add trailing slash to EventingUrl if missing', async () => { @@ -147,10 +145,7 @@ describe('SignalRService', () => { await signalRService.connectToHubWithEventingUrl(configWithoutSlash); - expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/eventingHub', - expect.any(Object), - ); + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith('https://api.example.com/eventingHub', expect.any(Object)); }); it('should use accessTokenFactory for geolocation hub authentication (no token in URL)', async () => { @@ -197,7 +192,7 @@ describe('SignalRService', () => { it('should not put access token with special chars in URL for geolocation hub', async () => { // Set up a token that needs encoding - mockGetState.mockReturnValue({ + mockGetState.mockReturnValue({ accessToken: 'token with spaces & special chars', refreshAccessToken: mockRefreshAccessToken, }); @@ -225,7 +220,7 @@ describe('SignalRService', () => { it('should not put complex access tokens in URL for geolocation hub', async () => { // Set up a complex token with various characters that need encoding - mockGetState.mockReturnValue({ + mockGetState.mockReturnValue({ accessToken: 'Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9+/=?#&', refreshAccessToken: mockRefreshAccessToken, }); @@ -280,27 +275,22 @@ describe('SignalRService', () => { await signalRService.connectToHubWithEventingUrl(configWithSlash); - expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith( - 'https://api.example.com/eventingHub', - expect.any(Object), - ); + expect(mockBuilderInstance.withUrl).toHaveBeenCalledWith('https://api.example.com/eventingHub', expect.any(Object)); }); it('should throw error if EventingUrl is not provided', async () => { const configWithoutUrl = { ...mockConfig, eventingUrl: '' }; - await expect(signalRService.connectToHubWithEventingUrl(configWithoutUrl)).rejects.toThrow( - 'EventingUrl is required for SignalR connection' - ); + await expect(signalRService.connectToHubWithEventingUrl(configWithoutUrl)).rejects.toThrow('EventingUrl is required for SignalR connection'); }); it('should not connect if already connected', async () => { // Connect first time await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Reset mocks to verify second call behavior jest.clearAllMocks(); - + // Try to connect again await signalRService.connectToHubWithEventingUrl(mockConfig); @@ -316,7 +306,13 @@ describe('SignalRService', () => { await expect(signalRService.connectToHubWithEventingUrl(mockConfig)).rejects.toThrow(error); - expect(mockLogger.error).toHaveBeenCalledWith({ + // Transient connect failures are retried by the reconnect logic, so they + // log at warn and never reach Sentry. + expect(mockLogger.warn).toHaveBeenCalledWith({ + message: `Failed to connect to hub: ${mockConfig.name}`, + context: { error }, + }); + expect(mockLogger.error).not.toHaveBeenCalledWith({ message: `Failed to connect to hub: ${mockConfig.name}`, context: { error }, }); @@ -355,7 +351,7 @@ describe('SignalRService', () => { it('should disconnect from hub successfully', async () => { // Connect first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Then disconnect await signalRService.disconnectFromHub(mockConfig.name); @@ -367,10 +363,10 @@ describe('SignalRService', () => { it('should handle disconnect errors gracefully', async () => { const error = new Error('Disconnect failed'); - + // Connect first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Mock stop to throw error mockConnection.stop.mockRejectedValue(error); @@ -399,10 +395,10 @@ describe('SignalRService', () => { it('should invoke method on connected hub', async () => { const methodData = { test: 'data' }; - + // Connect first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Invoke method await signalRService.invoke(mockConfig.name, 'testMethod', methodData); @@ -412,10 +408,10 @@ describe('SignalRService', () => { it('should handle invoke errors gracefully', async () => { const error = new Error('Invoke failed'); const methodData = { test: 'data' }; - + // Connect first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Mock invoke to throw error mockConnection.invoke.mockRejectedValue(error); @@ -428,9 +424,7 @@ describe('SignalRService', () => { }); it('should throw error if hub is not connected', async () => { - await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow( - 'Cannot invoke method testMethod on hub nonExistentHub: hub is not connected' - ); + await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow('Cannot invoke method testMethod on hub nonExistentHub: hub is not connected'); expect(mockConnection.invoke).not.toHaveBeenCalled(); }); @@ -473,37 +467,35 @@ describe('SignalRService', () => { it('should handle received messages and emit events', async () => { const eventCallback = jest.fn(); - + // Set up event listener signalRService.on('testMethod', eventCallback); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Get the registered callback for the method - const registeredCallback = mockConnection.on.mock.calls.find( - call => call[0] === 'testMethod' - )?.[1]; - + const registeredCallback = mockConnection.on.mock.calls.find((call) => call[0] === 'testMethod')?.[1]; + expect(registeredCallback).toBeDefined(); - + // Simulate receiving a message const testData = { message: 'test' }; registeredCallback!(testData); - + // Verify the event was emitted expect(eventCallback).toHaveBeenCalledWith(testData); }); it('should remove event listeners', () => { const eventCallback = jest.fn(); - + signalRService.on('testEvent', eventCallback); signalRService.off('testEvent', eventCallback); - + // Emit an event (this would be called internally) signalRService['emit']('testEvent', { test: 'data' }); - + // Callback should not have been called expect(eventCallback).not.toHaveBeenCalled(); }); @@ -545,7 +537,7 @@ describe('SignalRService', () => { it('should skip connection if hub is already reconnecting', async () => { // Set reconnecting state using new state management (signalRService as any).setHubState(mockConfig.name, HubConnectingState.RECONNECTING); - + // Try to connect await signalRService.connectToHubWithEventingUrl(mockConfig); @@ -569,14 +561,12 @@ describe('SignalRService', () => { it('should throw specific error when hub is reconnecting', async () => { // Set reconnecting state (signalRService as any).reconnectingHubs.add(mockConfig.name); - - await expect(signalRService.invoke(mockConfig.name, 'testMethod', {})) - .rejects.toThrow(`Cannot invoke method testMethod on hub ${mockConfig.name}: hub is currently reconnecting`); + + await expect(signalRService.invoke(mockConfig.name, 'testMethod', {})).rejects.toThrow(`Cannot invoke method testMethod on hub ${mockConfig.name}: hub is currently reconnecting`); }); it('should throw generic error when hub is not connected', async () => { - await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})) - .rejects.toThrow(`Cannot invoke method testMethod on hub nonExistentHub: hub is not connected`); + await expect(signalRService.invoke('nonExistentHub', 'testMethod', {})).rejects.toThrow(`Cannot invoke method testMethod on hub nonExistentHub: hub is not connected`); }); }); @@ -591,10 +581,10 @@ describe('SignalRService', () => { it('should clear reconnecting flag when disconnecting from connected hub', async () => { // Connect first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Set reconnecting state (signalRService as any).reconnectingHubs.add(mockConfig.name); - + // Disconnect await signalRService.disconnectFromHub(mockConfig.name); @@ -607,7 +597,7 @@ describe('SignalRService', () => { (signalRService as any).reconnectingHubs.add(mockConfig.name); (signalRService as any).reconnectAttempts.set(mockConfig.name, 2); (signalRService as any).hubConfigs.set(mockConfig.name, mockConfig); - + // Disconnect await signalRService.disconnectFromHub(mockConfig.name); @@ -628,13 +618,13 @@ describe('SignalRService', () => { it('should set reconnecting flag during reconnection attempt', async () => { jest.useFakeTimers(); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Spy on the connectToHubWithEventingUrl method const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockImplementation(() => { @@ -642,33 +632,33 @@ describe('SignalRService', () => { expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(true); return Promise.resolve(); }); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + // Advance timers to trigger reconnection jest.advanceTimersByTime(5000); await jest.runAllTicks(); - + // Should have called reconnection expect(connectSpy).toHaveBeenCalled(); - + jest.useRealTimers(); connectSpy.mockRestore(); }); it('should clear reconnecting flag on successful reconnection', async () => { jest.useFakeTimers(); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Spy on the connectToHubWithEventingUrl method to succeed const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockImplementation(async (config) => { @@ -676,33 +666,33 @@ describe('SignalRService', () => { (signalRService as any).reconnectingHubs.delete(config.name); return Promise.resolve(); }); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + // Advance timers to trigger reconnection jest.advanceTimersByTime(5000); await jest.runAllTicks(); - + // Should have cleared reconnecting flag expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false); - + jest.useRealTimers(); connectSpy.mockRestore(); }); it('should clear reconnecting flag on failed reconnection', async () => { jest.useFakeTimers(); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Spy on the connectToHubWithEventingUrl method to fail const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockImplementation(async (config) => { @@ -710,33 +700,33 @@ describe('SignalRService', () => { (signalRService as any).reconnectingHubs.delete(config.name); throw new Error('Reconnection failed'); }); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + // Advance timers to trigger reconnection jest.advanceTimersByTime(5000); await jest.runAllTicks(); - + // Should have cleared reconnecting flag even on failure expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false); - + jest.useRealTimers(); connectSpy.mockRestore(); }); it('should clear reconnecting flag when max attempts reached', async () => { jest.useFakeTimers(); - + // Connect to hub first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Get the onclose callback const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Set up spy to make reconnection attempts fail const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockImplementation(async (config) => { @@ -744,10 +734,10 @@ describe('SignalRService', () => { (signalRService as any).reconnectingHubs.delete(config.name); throw new Error('Connection failed'); }); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Simulate multiple failed reconnection attempts for (let i = 0; i < 6; i++) { onCloseCallback(); @@ -756,10 +746,10 @@ describe('SignalRService', () => { // Simulate each attempt failing by removing the connection (signalRService as any).connections.delete(mockConfig.name); } - + // Should have cleared reconnecting flag after max attempts expect((signalRService as any).reconnectingHubs.has(mockConfig.name)).toBe(false); - + jest.useRealTimers(); connectSpy.mockRestore(); }); @@ -797,7 +787,7 @@ describe('SignalRService', () => { mockHubConnectionBuilder.mockImplementation(() => mockBuilderInstance); // Reset auth store mock - mockGetState.mockReturnValue({ + mockGetState.mockReturnValue({ accessToken: 'mock-token', refreshAccessToken: mockRefreshAccessToken, }); @@ -808,75 +798,75 @@ describe('SignalRService', () => { it('should attempt reconnection on connection close', async () => { // Use fake timers to control setTimeout behavior jest.useFakeTimers(); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Verify onclose was called to register the callback expect(mockConnection.onclose).toHaveBeenCalled(); - + // Get the onclose callback from the first call const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Spy on the connectToHubWithEventingUrl method to track reconnection attempts const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockResolvedValue(); // Mock the reconnection call - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + // Advance timers by the exact reconnect interval (5000ms) jest.advanceTimersByTime(5000); - + // Wait for all promises to resolve await jest.runAllTicks(); - + // Should have called refreshAccessToken expect(mockRefreshAccessToken).toHaveBeenCalled(); - + // Should have called connectToHubWithEventingUrl for reconnection expect(connectSpy).toHaveBeenCalledWith(mockConfig); - + jest.useRealTimers(); connectSpy.mockRestore(); }, 10000); it('should stop reconnection attempts after max attempts', async () => { jest.useFakeTimers(); - + // Connect to hub first await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Verify onclose was called and get the callback expect(mockConnection.onclose).toHaveBeenCalled(); const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Now set up the spy to make reconnection attempts fail const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockRejectedValue(new Error('Connection failed')); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger the initial close — schedules attempt 1 onCloseCallback(); - + // Each failed attempt now reschedules automatically (retry-until-cap). // One large advance covers the full linear backoff schedule // (5s, 10s, ..., 50s — sum is 275s). await jest.advanceTimersByTimeAsync(600000); - + // Should have retried 10 times before giving up expect(connectSpy).toHaveBeenCalledTimes(10); - + // Should log max attempts reached error expect(mockLogger.error).toHaveBeenCalledWith({ message: `Max reconnection attempts (10) reached for hub: ${mockConfig.name}`, }); - + jest.useRealTimers(); connectSpy.mockRestore(); }); @@ -884,14 +874,14 @@ describe('SignalRService', () => { it('should reset reconnection attempts on successful reconnection', async () => { // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Verify onreconnected was called and get the callback expect(mockConnection.onreconnected).toHaveBeenCalled(); const onReconnectedCallback = mockConnection.onreconnected.mock.calls[0][0]; - + // Trigger reconnection onReconnectedCallback('new-connection-id'); - + expect(mockLogger.info).toHaveBeenCalledWith({ message: `Reconnected to hub: ${mockConfig.name}`, context: { connectionId: 'new-connection-id' }, @@ -900,52 +890,54 @@ describe('SignalRService', () => { it('should handle token refresh failure during reconnection', async () => { jest.useFakeTimers(); - + // Setup refresh token to fail mockRefreshAccessToken.mockRejectedValue(new Error('Token refresh failed')); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + // Verify onclose was called and get the callback expect(mockConnection.onclose).toHaveBeenCalled(); const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + // Spy on the connectToHubWithEventingUrl method to ensure it's not called when token refresh fails const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockResolvedValue(); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + // Fast-forward time to trigger the setTimeout callback await jest.advanceTimersByTimeAsync(5000); - + // Should have attempted to refresh token expect(mockRefreshAccessToken).toHaveBeenCalled(); - - // Should have logged the failure and rescheduled (retry-until-cap semantics) - expect(mockLogger.error).toHaveBeenCalledWith({ + + // Should have logged the failure and rescheduled (retry-until-cap semantics). + // Each individual attempt is transient, so it warns; only exhausting the + // full attempt budget reports an error to Sentry. + expect(mockLogger.warn).toHaveBeenCalledWith({ message: `Reconnection attempt 1/10 failed for hub: ${mockConfig.name}`, context: { error: expect.any(Error) }, }); - + // Should NOT have called connectToHubWithEventingUrl due to token refresh failure expect(connectSpy).not.toHaveBeenCalled(); - + // A retry should have been scheduled (attempt counter advanced) expect((signalRService as any).reconnectAttempts.get(mockConfig.name)).toBe(2); - + jest.useRealTimers(); connectSpy.mockRestore(); }); it('should abort reconnection and clean up when no token remains after refresh', async () => { jest.useFakeTimers(); - + // Refresh succeeds but yields no access token (logged out) mockRefreshAccessToken.mockImplementation(async () => { mockGetState.mockReturnValue({ @@ -953,35 +945,35 @@ describe('SignalRService', () => { refreshAccessToken: mockRefreshAccessToken, }); }); - + // Connect to hub await signalRService.connectToHubWithEventingUrl(mockConfig); - + const onCloseCallback = mockConnection.onclose.mock.calls[0][0]; - + const connectSpy = jest.spyOn(signalRService, 'connectToHubWithEventingUrl'); connectSpy.mockResolvedValue(); - + // Remove the connection to simulate it being closed (signalRService as any).connections.delete(mockConfig.name); - + // Trigger connection close onCloseCallback(); - + await jest.advanceTimersByTimeAsync(5000); - + // Should abort with a warn log expect(mockLogger.warn).toHaveBeenCalledWith({ message: `No valid authentication token after refresh, aborting reconnect for hub: ${mockConfig.name}`, }); - + // Should NOT attempt to reconnect expect(connectSpy).not.toHaveBeenCalled(); - + // Hub config and attempts should be cleaned up — no further retries expect((signalRService as any).hubConfigs.has(mockConfig.name)).toBe(false); expect((signalRService as any).reconnectAttempts.has(mockConfig.name)).toBe(false); - + jest.useRealTimers(); connectSpy.mockRestore(); }); diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index d954de02..2f5831fa 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -57,6 +57,11 @@ class CountlyService { Countly.events.recordEvent(eventName, segmentation, 1); + // A successful record proves the SDK is healthy. Without this the counter + // only ever grows, so two unrelated errors hours apart would disable + // analytics for the full disable timeout. + this.retryCount = 0; + if (this.enableLogging) { logger.debug({ message: 'Analytics event tracked successfully', diff --git a/src/services/aptabase.service.ts b/src/services/aptabase.service.ts index e1319dcc..2c0be0bb 100644 --- a/src/services/aptabase.service.ts +++ b/src/services/aptabase.service.ts @@ -57,6 +57,11 @@ class CountlyService { Countly.events.recordEvent(eventName, segmentation, 1); + // A successful record proves the SDK is healthy. Without this the counter + // only ever grows, so two unrelated errors hours apart would disable + // analytics for the full disable timeout. + this.retryCount = 0; + if (this.enableLogging) { logger.debug({ message: 'Analytics event tracked successfully', diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index aa011d1f..a66acc96 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -54,7 +54,9 @@ class BluetoothAudioService { private static instance: BluetoothAudioService; private connectedDevice: Device | null = null; private scanTimeout: ReturnType | null = null; - private connectionTimeout: NodeJS.Timeout | null = null; + // Guards connectToDevice against overlapping runs — discovery events can fire + // repeatedly for the preferred device while a connection is still in flight. + private isConnecting: boolean = false; private isInitialized: boolean = false; private hasAttemptedPreferredDeviceConnection: boolean = false; private eventListeners: { remove: () => void }[] = []; @@ -63,7 +65,7 @@ class BluetoothAudioService { private monitoringWatchdogInterval: ReturnType | null = null; private readPollingInterval: ReturnType | null = null; private isReadPollingInFlight: boolean = false; - private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null }[] = []; + private monitoredReadCharacteristics: { serviceUuid: string; characteristicUuid: string; lastHexValue: string | null; notificationConfirmed: boolean }[] = []; private mediaButtonEventListener: { remove: () => void } | null = null; private mediaButtonListeningActive: boolean = false; private pttPressActive: boolean = false; @@ -311,10 +313,37 @@ class BluetoothAudioService { return; } + // A real GATT notification arrived for this characteristic — the + // subscription is proven to work, so the read-polling fallback can stop + // polling it. + this.markNotificationConfirmed(data.service, data.characteristic); + // Handle button events based on service and characteristic UUIDs this.handleButtonEventFromCharacteristic(data.peripheral, data.service, data.characteristic, value); } + /** + * Record that a GATT notification was actually delivered for a characteristic. + * + * The read-polling fallback exists for devices whose notifications silently + * never fire (screen-off PTT must keep working), but polling every monitored + * characteristic every 700ms forever wastes battery/radio when notifications + * do work. Once a characteristic has delivered a notification it is excluded + * from polling; a re-subscribe (reconnect) rebuilds the entries unconfirmed, + * which resumes polling until notifications prove themselves again. + */ + private markNotificationConfirmed(serviceUuid: string, characteristicUuid: string): void { + for (const entry of this.monitoredReadCharacteristics) { + if (!entry.notificationConfirmed && this.areUuidsEqual(entry.serviceUuid, serviceUuid) && this.areUuidsEqual(entry.characteristicUuid, characteristicUuid)) { + entry.notificationConfirmed = true; + logger.info({ + message: 'GATT notifications confirmed for characteristic; read-polling fallback no longer needed for it', + context: { serviceUuid, characteristicUuid }, + }); + } + } + } + private handleScanStopped(): void { useBluetoothAudioStore.getState().setIsScanning(false); logger.info({ @@ -877,7 +906,7 @@ class BluetoothAudioService { // 1. This is the preferred device // 2. No device is currently connected // 3. We're not already in the process of connecting - if (preferredDevice?.id === device.id && !connectedDevice && !this.connectionTimeout) { + if (preferredDevice?.id === device.id && !connectedDevice && !this.isConnecting) { try { logger.info({ message: 'Auto-connecting to preferred Bluetooth device', @@ -926,6 +955,14 @@ class BluetoothAudioService { async connectToDevice(deviceId: string): Promise { if (this.isWeb) return; + if (this.isConnecting) { + logger.info({ + message: 'Bluetooth device connection already in progress, ignoring duplicate connect request', + context: { deviceId }, + }); + return; + } + this.isConnecting = true; try { useBluetoothAudioStore.getState().clearConnectionError(); useBluetoothAudioStore.getState().setIsConnecting(true); @@ -1038,6 +1075,8 @@ class BluetoothAudioService { useBluetoothAudioStore.getState().setIsConnecting(false); useBluetoothAudioStore.getState().setConnectionError(errorMessage); throw error; + } finally { + this.isConnecting = false; } } @@ -1472,6 +1511,7 @@ class BluetoothAudioService { serviceUuid, characteristicUuid, lastHexValue: null, + notificationConfirmed: false, }); } @@ -1494,6 +1534,13 @@ class BluetoothAudioService { return; } + // Every subscription has delivered a real notification — nothing left + // that needs the polling fallback. + if (this.monitoredReadCharacteristics.every((entry) => entry.notificationConfirmed)) { + this.stopReadPollingFallback(); + return; + } + if (this.isReadPollingInFlight) { return; } @@ -1507,6 +1554,11 @@ class BluetoothAudioService { private async pollReadCharacteristics(deviceId: string): Promise { for (const entry of this.monitoredReadCharacteristics) { + // Notifications are proven to work for this characteristic — reading it + // on a timer would only duplicate events and burn battery. + if (entry.notificationConfirmed) { + continue; + } try { const readValue = await BleManager.read(deviceId, entry.serviceUuid, entry.characteristicUuid); const nextHexValue = Buffer.from(readValue).toString('hex'); @@ -2423,9 +2475,23 @@ class BluetoothAudioService { } } - destroy(): void { - this.stopScanning(); - this.disconnectDevice(); + async destroy(): Promise { + try { + await this.stopScanning(); + } catch (error) { + logger.warn({ + message: 'Error stopping scan during Bluetooth service destroy', + context: { error }, + }); + } + try { + await this.disconnectDevice(); + } catch (error) { + logger.warn({ + message: 'Error disconnecting device during Bluetooth service destroy', + context: { error }, + }); + } useBluetoothAudioStore.getState().setIsHeadsetButtonMonitoring(false); this.clearPttReleaseFallback(); this.clearMicApplyRetry(); @@ -2441,14 +2507,8 @@ class BluetoothAudioService { }); this.eventListeners = []; - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = null; - } - // Reset initialization flags this.isInitialized = false; - this.isInitialized = false; this.hasAttemptedPreferredDeviceConnection = false; } diff --git a/src/services/location.ts b/src/services/location.ts index 3019ec40..003691ec 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -184,6 +184,13 @@ class LocationService { private backgroundSubscription: Location.LocationSubscription | null = null; private appStateSubscription: { remove: () => void } | null = null; private isBackgroundGeolocationEnabled = false; + // Single-flight guards. The subscription fields are only assigned after an + // await, so a plain "already subscribed?" check lets two concurrent starts + // both pass it and create duplicate watchers — and lets a stop run in the gap + // and drop the fresh subscription. Concurrent callers share the in-flight + // promise instead, and stop* waits for it before tearing anything down. + private startPromise: Promise | null = null; + private startBackgroundPromise: Promise | null = null; private constructor() { this.initializeAppStateListener(); @@ -208,10 +215,20 @@ class LocationService { context: { nextAppState, backgroundEnabled: this.isBackgroundGeolocationEnabled }, }); - if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) { - await this.startBackgroundUpdates(); - } else if (nextAppState === 'active') { - await this.stopBackgroundUpdates(); + // AppState invokes this without awaiting, so anything that throws here (a + // permission revoked while backgrounded is the common one) becomes an + // unhandled rejection instead of a recoverable, logged failure. + try { + if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) { + await this.startBackgroundUpdates(); + } else if (nextAppState === 'active') { + await this.stopBackgroundUpdates(); + } + } catch (error) { + logger.warn({ + message: 'Failed to handle location app state change', + context: { error, nextAppState }, + }); } }; @@ -239,6 +256,20 @@ class LocationService { } async startLocationUpdates(): Promise { + if (this.startPromise) { + return this.startPromise; + } + + const promise = this.performStartLocationUpdates().finally(() => { + if (this.startPromise === promise) { + this.startPromise = null; + } + }); + this.startPromise = promise; + return promise; + } + + private async performStartLocationUpdates(): Promise { // On web, use a lightweight browser geolocation watcher instead of expo-location/TaskManager if (isWeb) { if (!('geolocation' in navigator)) { @@ -357,6 +388,20 @@ class LocationService { } async startBackgroundUpdates(): Promise { + if (this.startBackgroundPromise) { + return this.startBackgroundPromise; + } + + const promise = this.performStartBackgroundUpdates().finally(() => { + if (this.startBackgroundPromise === promise) { + this.startBackgroundPromise = null; + } + }); + this.startBackgroundPromise = promise; + return promise; + } + + private async performStartBackgroundUpdates(): Promise { if (isWeb) return; // Background location not supported on web if (this.backgroundSubscription || !this.isBackgroundGeolocationEnabled) { return; @@ -401,6 +446,11 @@ class LocationService { async stopBackgroundUpdates(): Promise { if (isWeb) return; + // A start still in flight assigns its subscription after this runs, which + // would leave an orphaned watcher running forever. + if (this.startBackgroundPromise) { + await this.startBackgroundPromise.catch(() => {}); + } if (this.backgroundSubscription) { logger.info({ message: 'Stopping background location updates', @@ -472,6 +522,13 @@ class LocationService { } async stopLocationUpdates(): Promise { + // Wait out any start still in flight: it assigns this.locationSubscription + // after its await, so stopping first would clear a null field and leave the + // watcher that lands afterwards running with nothing tracking it. + if (this.startPromise) { + await this.startPromise.catch(() => {}); + } + if (this.locationSubscription) { if (isWeb) { // On web the subscription is our own shim wrapping clearWatch diff --git a/src/services/offline-event-manager.service.ts b/src/services/offline-event-manager.service.ts index f5ff88d4..36e58187 100644 --- a/src/services/offline-event-manager.service.ts +++ b/src/services/offline-event-manager.service.ts @@ -16,7 +16,7 @@ import { } from '@/models/offline-queue/queued-event'; import { SaveUnitLocationInput } from '@/models/v4/unitLocation/saveUnitLocationInput'; import { SaveUnitStatusInput, SaveUnitStatusRoleInput } from '@/models/v4/unitStatus/saveUnitStatusInput'; -import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store'; class OfflineEventManager { private static instance: OfflineEventManager; @@ -48,10 +48,36 @@ class OfflineEventManager { // Initialize network listener useOfflineQueueStore.getState().initializeNetworkListener(); + // The processing timer stops itself once the queue has no work left, so the + // queue wakes it again when an event is enqueued, retried, or the device + // comes back online. + setOfflineQueueActivityListener(() => { + if (AppState.currentState !== 'inactive') { + this.startProcessing(); + } + }); + // Start processing when app becomes active this.handleAppStateChange(AppState.currentState); } + /** + * True when the queue still holds something the processor can act on. Events + * whose retries are exhausted are inert, so a queue holding only those must + * not keep the 10s timer alive. + */ + private hasPendingWork(): boolean { + const store = useOfflineQueueStore.getState(); + + if (store.getPendingEvents().length > 0) { + return true; + } + + // Events parked on a retry backoff are not pending *yet*, but they will be — + // dropping the timer now would strand them until the next enqueue. + return (store.queuedEvents ?? []).some((event) => event.status === QueuedEventStatus.PROCESSING || (event.status === QueuedEventStatus.FAILED && event.retryCount < event.maxRetries)); + } + /** * Start background processing of queued events */ @@ -63,6 +89,15 @@ class OfflineEventManager { return; } + // Nothing to drain — starting the timer here would just tick against an + // empty queue until something is enqueued, which wakes it anyway. + if (!this.hasPendingWork()) { + logger.debug({ + message: 'Offline queue is empty, not starting event processing', + }); + return; + } + logger.info({ message: 'Starting offline event processing', }); @@ -206,9 +241,21 @@ class OfflineEventManager { return; } + // Age out permanently failed events before deciding whether there is work + // left — a queue holding only dead events is an idle queue. + useOfflineQueueStore.getState().pruneFailedEvents(); + + if (!this.hasPendingWork()) { + // Nothing actionable: stop the timer instead of ticking every 10s forever. + // addEvent / retry / NetInfo reconnect restart it via the activity listener. + this.stopProcessing(); + return; + } + const store = useOfflineQueueStore.getState(); - // Don't process if offline + // Don't process if offline. The timer stays armed so the queue drains as + // soon as connectivity returns. if (!store.isConnected || !store.isNetworkReachable) { logger.debug({ message: 'Device is offline, skipping event processing', @@ -408,6 +455,7 @@ class OfflineEventManager { */ public cleanup(): void { this.stopProcessing(); + setOfflineQueueActivityListener(null); if (this.appStateSubscription) { this.appStateSubscription.remove(); diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 0d782a49..ef73d22f 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -272,7 +272,9 @@ class SignalRService { // Clear the direct-connecting state on failed connection this.setHubState(config.name, HubConnectingState.IDLE); - logger.error({ + // Transient network/backend failure — reconnect logic retries, so warn + // rather than reporting every attempt to Sentry. + logger.warn({ message: `Failed to connect to hub: ${config.name}`, context: { error }, }); @@ -343,6 +345,21 @@ class SignalRService { context: { config }, }); + // Store an equivalent eventing-style config so handleConnectionClose can + // rebuild this connection after the automatic-reconnect budget is + // exhausted — manual reconnection always goes through + // connectToHubWithEventingUrl, which re-appends the hub name to the path. + const parsedUrl = new URL(config.url); + const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); + const derivedHubName = pathSegments.pop() ?? ''; + const basePath = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/'; + this.hubConfigs.set(config.name, { + name: config.name, + eventingUrl: `${parsedUrl.protocol}//${parsedUrl.host}${basePath}${parsedUrl.search}`, + hubName: derivedHubName, + methods: config.methods, + }); + const signalRLogLevel = Platform.OS === 'web' ? LogLevel.Warning : LogLevel.Information; const connection = new HubConnectionBuilder() @@ -410,7 +427,9 @@ class SignalRService { // Clear the direct-connecting state on failed connection this.setHubState(config.name, HubConnectingState.IDLE); - logger.error({ + // Transient network/backend failure — reconnect logic retries, so warn + // rather than reporting every attempt to Sentry. + logger.warn({ message: `Failed to connect to hub: ${config.name}`, context: { error }, }); @@ -525,7 +544,9 @@ class SignalRService { // dead until the next app background/resume cycle. this.setHubState(hubName, HubConnectingState.IDLE); - logger.error({ + // Each failed attempt is a transient, retried failure — only the + // max-attempts-exhausted case above reports to Sentry as an error. + logger.warn({ message: `Reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} failed for hub: ${hubName}`, context: { error }, }); diff --git a/src/stores/app/__tests__/core-store.test.ts b/src/stores/app/__tests__/core-store.test.ts index e1c9c990..fd143c7c 100644 --- a/src/stores/app/__tests__/core-store.test.ts +++ b/src/stores/app/__tests__/core-store.test.ts @@ -65,6 +65,9 @@ import { getActiveUnitId, getActiveCallId } from '@/lib/storage/app'; import { getConfig } from '@/api/config'; import { logger } from '@/lib/logging'; import { GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; +import { getAllUnitStatuses } from '@/api/satuses/statuses'; +import { getUnitStatus } from '@/api/units/unitStatuses'; +import { useUnitsStore } from '@/stores/units/store'; const mockGetActiveUnitId = getActiveUnitId as jest.MockedFunction; const mockGetActiveCallId = getActiveCallId as jest.MockedFunction; @@ -184,7 +187,7 @@ describe('Core Store', () => { it('should fetch config first during initialization', async () => { mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); - + const mockConfigData = { EventingUrl: 'https://eventing.example.com/', GoogleMapsKey: 'test-google-key', @@ -210,7 +213,7 @@ describe('Core Store', () => { it('should handle config fetch errors during initialization', async () => { mockGetActiveUnitId.mockReturnValue(null); mockGetActiveCallId.mockReturnValue(null); - + const configError = new Error('Failed to fetch config'); mockGetConfig.mockRejectedValue(configError); @@ -309,6 +312,74 @@ describe('Core Store', () => { }); }); + describe('setActiveUnit', () => { + const activeUnit = { UnitId: 'unit-1', Name: 'Engine 6', Type: '3' } as any; + const unitStatuses = [ + { UnitType: '0', StatusId: 's0', Statuses: [{ Text: 'Available' }] }, + { UnitType: '3', StatusId: 's3', Statuses: [{ Text: 'Available' }] }, + ] as any[]; + + beforeEach(() => { + (useUnitsStore.getState as jest.Mock).mockReturnValue({ + fetchUnits: jest.fn(async () => undefined), + units: [activeUnit], + unitStatuses, + }); + (getUnitStatus as jest.Mock).mockImplementation(async () => ({ Data: { State: 'Available' } })); + }); + + it('should reuse the statuses fetchUnits already loaded instead of refetching them', async () => { + await useCoreStore.getState().setActiveUnit('unit-1'); + + // fetchUnits() already issues /Statuses/GetAllUnitStatuses — calling it a + // second time here was a duplicate request on every unit selection. + expect(getAllUnitStatuses).not.toHaveBeenCalled(); + }); + + it('should resolve activeStatuses for the unit type from the already-fetched data', async () => { + await useCoreStore.getState().setActiveUnit('unit-1'); + + expect(useCoreStore.getState().activeStatuses).toEqual(unitStatuses[1]); + expect(useCoreStore.getState().activeUnit).toEqual(activeUnit); + }); + + it('should fall back to the default unit type statuses when the type has none', async () => { + (useUnitsStore.getState as jest.Mock).mockReturnValue({ + fetchUnits: jest.fn(async () => undefined), + units: [{ ...activeUnit, Type: '99' }], + unitStatuses, + }); + + await useCoreStore.getState().setActiveUnit('unit-1'); + + expect(useCoreStore.getState().activeStatuses).toEqual(unitStatuses[0]); + }); + + it('should log a network failure at warn rather than error', async () => { + // Axios "Network Error" — no response received (offline / background launch) + const networkError = Object.assign(new Error('Network Error'), { + isAxiosError: true, + code: 'ERR_NETWORK', + }); + (useUnitsStore.getState as jest.Mock).mockReturnValue({ + fetchUnits: jest.fn(async () => { + throw networkError; + }), + units: [], + unitStatuses: [], + }); + + await useCoreStore.getState().setActiveUnit('unit-1'); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to set active unit due to network connectivity', + }) + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + }); + describe('Store State', () => { it('should have correct initial state', () => { const { result } = renderHook(() => useCoreStore()); diff --git a/src/stores/app/__tests__/livekit-store-room-switch.test.ts b/src/stores/app/__tests__/livekit-store-room-switch.test.ts index 4758e2f4..a42b360b 100644 --- a/src/stores/app/__tests__/livekit-store-room-switch.test.ts +++ b/src/stores/app/__tests__/livekit-store-room-switch.test.ts @@ -238,4 +238,54 @@ describe('LiveKit store room switching', () => { isConnected: true, }); }); + + it('disconnects the room it created when connect fails, so no live room leaks', async () => { + const room = createMockRoom('failed-participant'); + room.connect.mockRejectedValue(new Error('connect refused')); + room.disconnect.mockResolvedValue(undefined); + (Room as unknown as jest.Mock).mockImplementationOnce(() => room); + + await useLiveKitStore.getState().connectToRoom(createRoomInfo('bad', 'Bad Room'), 'token'); + + // withTimeout rejects without cancelling room.connect(), so the room object + // must be torn down explicitly or its websocket/audio session stays open. + expect(room.disconnect).toHaveBeenCalled(); + expect(useLiveKitStore.getState().currentRoom).toBeNull(); + expect(useLiveKitStore.getState().isConnecting).toBe(false); + }); + + it('disconnects the room it created when a post-connect step throws', async () => { + const room = createMockRoom('post-connect-participant'); + room.disconnect.mockResolvedValue(undefined); + (Room as unknown as jest.Mock).mockImplementationOnce(() => room); + + await useLiveKitStore.getState().connectToRoom(createRoomInfo('ok', 'Room'), 'token'); + expect(useLiveKitStore.getState().currentRoom).toBe(room); + + // A room already committed to the store is owned by the store's own + // teardown paths and must NOT be disconnected by the failure handler. + room.disconnect.mockClear(); + + const second = createMockRoom('second-participant'); + second.connect.mockRejectedValue(new Error('boom')); + second.disconnect.mockResolvedValue(undefined); + (Room as unknown as jest.Mock).mockImplementationOnce(() => second); + + useLiveKitStore.setState({ isConnecting: false, isConnected: false }); + await useLiveKitStore.getState().connectToRoom(createRoomInfo('two', 'Room Two'), 'token'); + + expect(second.disconnect).toHaveBeenCalled(); + }); + + it('swallows a disconnect failure in the error path rather than masking the original error', async () => { + const room = createMockRoom('unstoppable'); + room.connect.mockRejectedValue(new Error('connect refused')); + room.disconnect.mockRejectedValue(new Error('disconnect also failed')); + (Room as unknown as jest.Mock).mockImplementationOnce(() => room); + + await expect(useLiveKitStore.getState().connectToRoom(createRoomInfo('bad', 'Bad Room'), 'token')).resolves.toBeUndefined(); + + expect(room.disconnect).toHaveBeenCalled(); + expect(useLiveKitStore.getState().isConnecting).toBe(false); + }); }); diff --git a/src/stores/app/core-store.ts b/src/stores/app/core-store.ts index dc61688b..4f94b211 100644 --- a/src/stores/app/core-store.ts +++ b/src/stores/app/core-store.ts @@ -4,7 +4,6 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { getConfig } from '@/api/config'; -import { getAllUnitStatuses } from '@/api/satuses/statuses'; import { getUnitStatus } from '@/api/units/unitStatuses'; import { logger } from '@/lib/logging'; import { zustandStorage } from '@/lib/storage'; @@ -144,12 +143,14 @@ export const useCoreStore = create()( const unitStatuses = useUnitsStore.getState().unitStatuses; const activeUnit = units.find((unit) => unit.UnitId === unitId); if (activeUnit) { + // fetchUnits() above already fetched /Statuses/GetAllUnitStatuses and + // stored the identical payload as unitStatuses — reuse it instead of + // issuing the same request a second time. let activeStatuses: UnitTypeStatusResultData | undefined = undefined; - const allStatuses = await getAllUnitStatuses(); - const defaultStatuses = find(allStatuses.Data, ['UnitType', '0']); + const defaultStatuses = find(unitStatuses, ['UnitType', '0']); if (activeUnit.Type) { - const statusesForType = find(allStatuses.Data, ['UnitType', activeUnit.Type.toString()]); + const statusesForType = find(unitStatuses, ['UnitType', activeUnit.Type.toString()]); if (statusesForType) { activeStatuses = statusesForType; @@ -205,10 +206,17 @@ export const useCoreStore = create()( //await useRolesStore.getState().fetchRolesForUnit(unitId); } catch (error) { set({ error: 'Failed to set active unit', isLoading: false }); - logger.error({ - message: 'Failed to set active unit', - context: { error }, - }); + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to set active unit due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: 'Failed to set active unit', + context: { error }, + }); + } } }, setActiveUnitWithFetch: async (unitId: string) => { @@ -231,10 +239,17 @@ export const useCoreStore = create()( error: 'Failed to fetch and set active unit', isLoading: false, }); - logger.error({ - message: 'Failed to fetch and set active unit', - context: { error }, - }); + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to fetch and set active unit due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: 'Failed to fetch and set active unit', + context: { error }, + }); + } } }, // Lightweight status-only refresh — used by the SignalR status hook so a @@ -246,10 +261,19 @@ export const useCoreStore = create()( set({ activeUnitStatus: unitStatus.Data }); } } catch (error) { - logger.error({ - message: 'Failed to refresh active unit status', - context: { error }, - }); + // Driven by SignalR unitStatusUpdated events, so a transient failure is + // repaired by the next event or the next resume. + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to refresh active unit status due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: 'Failed to refresh active unit status', + context: { error }, + }); + } } }, setActiveCall: async (callId: string | null) => { diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 785346ab..3c055457 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -457,6 +457,12 @@ export const useLiveKitStore = create((set, get) => ({ } }, CONNECT_OVERALL_TIMEOUT_MS); + // The room created by this attempt, kept in outer scope so the failure path + // can always tear it down — withTimeout rejects without cancelling + // room.connect(), so a late-completing connect would otherwise leak a live + // room (open websocket + audio session). + let connectingRoom: Room | null = null; + try { const { currentRoom, voipServerWebsocketSslAddress } = get(); @@ -538,6 +544,7 @@ export const useLiveKitStore = create((set, get) => ({ // Create a new room const room = new Room(); + connectingRoom = room; // Setup room event listeners room.on(RoomEvent.ParticipantConnected, (participant) => { @@ -795,6 +802,15 @@ export const useLiveKitStore = create((set, get) => ({ context: { error, roomName: roomInfo?.Name }, }); + // Always tear down the locally created room: a connect that completes + // after the timeout rejection would otherwise stay live (websocket + + // audio) with nothing referencing it. Only rooms not committed to the + // store are torn down here — the intentional room-switch path manages the + // committed room itself (isConnected is cleared before it disconnects). + if (connectingRoom && get().currentRoom !== connectingRoom) { + await connectingRoom.disconnect().catch(() => {}); + } + // Stop audio session on failure since we started it above if (Platform.OS !== 'web') { try { diff --git a/src/stores/auth/__tests__/store-cold-start.test.ts b/src/stores/auth/__tests__/store-cold-start.test.ts new file mode 100644 index 00000000..606d23d1 --- /dev/null +++ b/src/stores/auth/__tests__/store-cold-start.test.ts @@ -0,0 +1,361 @@ +/** + * Cold-start session restore, logout idempotence and the refresh single-flight. + * + * These three behaviours are load-bearing for an emergency-response app: a + * responder relaunching the app must be signed in instantly (and stay signed in + * offline), a backend incident must never log anyone out, and a genuine + * credential rejection must wipe the session exactly once. + */ +import { AxiosError, type AxiosResponse } from 'axios'; + +const mockLoginRequest = jest.fn(); +const mockSsoExternalTokenRequest = jest.fn(); +const mockRefreshTokenRequest = jest.fn(); + +jest.mock('@/lib/auth/api', () => ({ + loginRequest: (...args: unknown[]) => mockLoginRequest(...args), + ssoExternalTokenRequest: (...args: unknown[]) => mockSsoExternalTokenRequest(...args), + refreshTokenRequest: (...args: unknown[]) => mockRefreshTokenRequest(...args), +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/lib/storage', () => ({ + zustandStorage: { + // Nothing persisted: the store rehydrates empty and each test drives + // restoreSession() with the persisted shape it wants to exercise. + getItem: jest.fn(() => null), + setItem: jest.fn(), + removeItem: jest.fn(), + }, + setItem: jest.fn(), + removeItem: jest.fn(), + getItem: jest.fn(() => null), +})); + +jest.mock('@/lib/cache/cache-manager', () => ({ + cacheManager: { clear: jest.fn(), remove: jest.fn(), prune: jest.fn() }, +})); + +jest.mock('@/lib/cache/cache-scope', () => ({ + setCacheScope: jest.fn(), + clearCacheScope: jest.fn(), +})); + +import { registerSessionCleanupHandler } from '@/lib/auth/session-cleanup'; + +import { resetInFlightRefresh } from '../../../lib/auth/refresh-lock'; +import useAuthStore, { restoreSession } from '../store'; + +/** A JWT-shaped access token whose payload is base64url-encoded, expiring at `expiresAtMs`. */ +const makeAccessToken = (expiresAtMs: number): string => { + const encoded = Buffer.from(JSON.stringify({ sub: 'user-1', exp: Math.floor(expiresAtMs / 1000) })) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `header.${encoded}.signature`; +}; + +const axiosErrorWithStatus = (status: number): AxiosError => { + const response = { status, statusText: '', data: {}, headers: {}, config: {} as never } as AxiosResponse; + return new AxiosError('Request failed', 'ERR_BAD_RESPONSE', undefined, undefined, response); +}; + +const networkError = (): AxiosError => new AxiosError('Network Error', 'ERR_NETWORK'); + +const profile = { sub: 'user-1', name: 'Unit 1' } as never; + +const refreshResponse = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + id_token: 'header.payload.signature', + expires_in: 3600, + token_type: 'Bearer', + expiration_date: '', +}; + +const sessionCleanup = jest.fn(async () => undefined); + +const resetStore = () => { + useAuthStore.setState({ + accessToken: null, + refreshToken: null, + refreshTokenExpiresOn: null, + status: 'idle', + error: null, + profile: null, + userId: null, + refreshTimeoutId: null, + }); +}; + +describe('auth store cold start', () => { + beforeAll(() => { + registerSessionCleanupHandler(sessionCleanup); + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + // Pin the clock to a whole second: a JWT `exp` claim has second resolution, + // so a sub-second "now" would make the scheduled refresh delay non-deterministic. + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + resetInFlightRefresh(); + resetStore(); + mockRefreshTokenRequest.mockResolvedValue(refreshResponse); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('restoreSession', () => { + it('restores a valid access token instantly — no delay, no network on the critical path', async () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + + restoreSession({ + accessToken: makeAccessToken(expiresAt), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(expiresAt), + profile, + }); + + // Signed in synchronously: no 2s wait, no refresh round-trip first. + expect(useAuthStore.getState().status).toBe('signedIn'); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + + // Still no network after the old fixed 2s delay would have elapsed. + await jest.advanceTimersByTimeAsync(2000); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + expect(useAuthStore.getState().status).toBe('signedIn'); + }); + + it('schedules the proactive refresh one minute before the token expires', async () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + + restoreSession({ + accessToken: makeAccessToken(expiresAt), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(expiresAt), + profile, + }); + + // One second before the scheduled refresh: still nothing. + await jest.advanceTimersByTimeAsync(59 * 60 * 1000 - 1); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(1); + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + }); + + it('falls back to the persisted expiry when the access token is opaque (not a JWT)', () => { + restoreSession({ + accessToken: 'opaque-access-token', + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(Date.now() + 60 * 60 * 1000), + profile, + }); + + expect(useAuthStore.getState().status).toBe('signedIn'); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + }); + + it('keeps an expired access token signed in and refreshes in the background', async () => { + restoreSession({ + accessToken: makeAccessToken(Date.now() - 60 * 1000), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(Date.now() - 60 * 1000), + profile, + }); + + // Optimistically signed in: the 401 interceptor covers the expired token, + // so the user never sees the login form. + expect(useAuthStore.getState().status).toBe('signedIn'); + + await jest.advanceTimersByTimeAsync(1); + + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState().accessToken).toBe('new-access-token'); + expect(useAuthStore.getState().status).toBe('signedIn'); + }); + + it('treats a token expiring within the skew window as expired', async () => { + restoreSession({ + accessToken: makeAccessToken(Date.now() + 10 * 1000), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(Date.now() + 10 * 1000), + profile, + }); + + expect(useAuthStore.getState().status).toBe('signedIn'); + await jest.advanceTimersByTimeAsync(1); + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + }); + + it('refreshes immediately (no fixed delay) when only a refresh token was persisted', async () => { + restoreSession({ + accessToken: null, + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: null, + profile, + }); + + // Nothing to authorize requests with, so loading is correct here — but the + // refresh starts right away rather than after an arbitrary 2s wait. + expect(useAuthStore.getState().status).toBe('loading'); + await jest.advanceTimersByTimeAsync(1); + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no session was persisted', () => { + restoreSession({ accessToken: null, refreshToken: null, refreshTokenExpiresOn: null, profile: null }); + restoreSession(undefined); + + expect(useAuthStore.getState().status).toBe('idle'); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + }); + + it('never leaves the persisted status as loading when tokens are usable', () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + restoreSession({ + accessToken: makeAccessToken(expiresAt), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(expiresAt), + profile, + }); + + expect(useAuthStore.getState().status).not.toBe('loading'); + }); + }); + + describe('offline cold start', () => { + it('keeps the session (does not bounce to login) when the refresh fails offline', async () => { + mockRefreshTokenRequest.mockRejectedValue(networkError()); + + restoreSession({ + accessToken: makeAccessToken(Date.now() - 60 * 1000), + refreshToken: 'stored-refresh-token', + refreshTokenExpiresOn: String(Date.now() - 60 * 1000), + profile, + }); + + expect(useAuthStore.getState().status).toBe('signedIn'); + + await jest.advanceTimersByTimeAsync(1); + + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + // Session preserved, refresh token intact, cleanup never ran. + expect(useAuthStore.getState().status).toBe('signedIn'); + expect(useAuthStore.getState().refreshToken).toBe('stored-refresh-token'); + expect(sessionCleanup).not.toHaveBeenCalled(); + }); + + it('retries the failed refresh in the background after 30s', async () => { + mockRefreshTokenRequest.mockRejectedValue(networkError()); + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await useAuthStore.getState().refreshAccessToken(); + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + + resetInFlightRefresh(); + await jest.advanceTimersByTimeAsync(30000); + + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(2); + expect(useAuthStore.getState().status).toBe('signedIn'); + }); + + it.each([500, 502, 503, 429])('preserves the session when the token endpoint returns %i', async (status) => { + mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(status)); + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await useAuthStore.getState().refreshAccessToken(); + + expect(useAuthStore.getState().status).toBe('signedIn'); + expect(sessionCleanup).not.toHaveBeenCalled(); + }); + }); + + describe('credential rejection', () => { + it.each([400, 401])('logs out when the token endpoint rejects the refresh token (%i)', async (status) => { + mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(status)); + useAuthStore.setState({ refreshToken: 'stored-refresh-token', accessToken: 'stale', status: 'signedIn', userId: 'user-1' }); + + await useAuthStore.getState().refreshAccessToken(); + + expect(useAuthStore.getState().status).toBe('signedOut'); + expect(useAuthStore.getState().accessToken).toBeNull(); + expect(useAuthStore.getState().refreshToken).toBeNull(); + expect(sessionCleanup).toHaveBeenCalledTimes(1); + }); + }); + + describe('logout idempotence', () => { + it('runs the session cleanup once when concurrent refreshes are all rejected', async () => { + mockRefreshTokenRequest.mockRejectedValue(axiosErrorWithStatus(401)); + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + // Three independent callers (proactive timer + two queued 401s) race. + await Promise.all([useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken()]); + + expect(sessionCleanup).toHaveBeenCalledTimes(1); + expect(useAuthStore.getState().status).toBe('signedOut'); + }); + + it('runs the cleanup once for concurrent direct logout() calls', async () => { + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await Promise.all([useAuthStore.getState().logout(), useAuthStore.getState().logout()]); + + expect(sessionCleanup).toHaveBeenCalledTimes(1); + }); + + it('still performs a later logout after the guard has settled', async () => { + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + await useAuthStore.getState().logout(); + expect(sessionCleanup).toHaveBeenCalledTimes(1); + + useAuthStore.setState({ refreshToken: 'second-session', status: 'signedIn' }); + await useAuthStore.getState().logout(); + + expect(sessionCleanup).toHaveBeenCalledTimes(2); + expect(useAuthStore.getState().status).toBe('signedOut'); + }); + }); + + describe('refresh single-flight', () => { + it('shares one token request across concurrent callers', async () => { + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + const results = await Promise.all([useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken(), useAuthStore.getState().refreshAccessToken()]); + + // Rotation-safe: the refresh token is presented to the server exactly once. + expect(mockRefreshTokenRequest).toHaveBeenCalledTimes(1); + expect(results).toEqual([true, true, true]); + expect(useAuthStore.getState().accessToken).toBe('new-access-token'); + expect(useAuthStore.getState().refreshToken).toBe('new-refresh-token'); + }); + + it('records the new access-token expiry so the next cold start can restore instantly', async () => { + useAuthStore.setState({ refreshToken: 'stored-refresh-token', status: 'signedIn' }); + + await useAuthStore.getState().refreshAccessToken(); + + const expiresOn = Number(useAuthStore.getState().refreshTokenExpiresOn); + expect(expiresOn).toBe(Date.now() + refreshResponse.expires_in * 1000); + }); + + it('logs out when there is no refresh token at all', async () => { + useAuthStore.setState({ refreshToken: null, status: 'signedIn' }); + + const result = await useAuthStore.getState().refreshAccessToken(); + + expect(result).toBe(false); + expect(mockRefreshTokenRequest).not.toHaveBeenCalled(); + expect(useAuthStore.getState().status).toBe('signedOut'); + }); + }); +}); diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 151de100..b22ff6dc 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -1,5 +1,4 @@ import * as Sentry from '@sentry/react-native'; -import base64 from 'react-native-base64'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; @@ -8,14 +7,21 @@ import { clearCacheScope, setCacheScope } from '@/lib/cache/cache-scope'; import { logger } from '@/lib/logging'; import { loginRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; +import { decodeJwtPayload, getJwtExpiryMs } from '../../lib/auth/jwt'; import { refreshTokenSingleFlight } from '../../lib/auth/refresh-lock'; import { runSessionCleanup } from '../../lib/auth/session-cleanup'; import { isRefreshCredentialRejection } from '../../lib/auth/token-refresh'; import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; -import { getAuth } from '../../lib/auth/utils'; import { removeItem, setItem, zustandStorage } from '../../lib/storage'; +// Single-flight guard for logout: on a refresh credential rejection every queued +// 401 caller independently reaches logout(), and without a guard the full +// session cleanup (app-data wipe) runs once per caller, concurrently. All +// concurrent callers share one logout run; the guard clears when it settles so +// a later, genuine logout still executes. +let logoutInFlight: Promise | null = null; + const useAuthStore = create()( persist( (set, get) => ({ @@ -34,7 +40,7 @@ const useAuthStore = create()( const response = await loginRequest(credentials); if (response.successful) { - const payload = sanitizeJson(base64.decode(response.authResponse!.id_token!.split('.')[1])); + const payload = sanitizeJson(decodeJwtPayload(response.authResponse!.id_token!)); setItem('authResponse', response.authResponse!); const now = new Date(); @@ -100,7 +106,7 @@ const useAuthStore = create()( const response = await ssoExternalTokenRequest(credentials); if (response.successful && response.authResponse) { - const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1])); + const payload = sanitizeJson(decodeJwtPayload(response.authResponse.id_token!)); setItem('authResponse', response.authResponse); const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString(); @@ -143,50 +149,63 @@ const useAuthStore = create()( }, logout: async () => { - // Clear any pending refresh timer to prevent stacked timeouts - const existingTimeoutId = get().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); + // Single-flight: concurrent logout triggers (every 401 caller queued + // behind a rejected refresh) share one run so the full data wipe never + // executes twice in parallel. + if (logoutInFlight) { + return logoutInFlight; } - set({ - accessToken: null, - refreshToken: null, - status: 'signedOut', - error: null, - profile: null, - // Clearing the user id is what drops the API cache scope; leaving it set keeps this user's - // cache keys live for whoever signs in next on the same device. - userId: null, - isFirstTime: true, - refreshTimeoutId: null, - }); - Sentry.setUser(null); - // Remove the standalone stored auth response so no valid refresh - // token is left on the device after logout. - try { - await removeItem('authResponse'); - } catch (error) { - logger.warn({ - message: 'Failed to remove stored auth response on logout', - context: { error }, + logoutInFlight = (async () => { + // Clear any pending refresh timer to prevent stacked timeouts + const existingTimeoutId = get().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + set({ + accessToken: null, + refreshToken: null, + status: 'signedOut', + error: null, + profile: null, + // Clearing the user id is what drops the API cache scope; leaving it set keeps this user's + // cache keys live for whoever signs in next on the same device. + userId: null, + isFirstTime: true, + refreshTimeoutId: null, }); - } + Sentry.setUser(null); - // Route EVERY logout (manual, forced by the 401 interceptor, refresh - // credential rejection) through the full app-data reset so a different - // user logging in on the same device never sees the previous user's - // data — and the previous user's queued offline events never replay - // under the new account. The reset service registers its handler in - // the leaf session-cleanup module (avoids a static import cycle). - try { - await runSessionCleanup(); - } catch (error) { - logger.error({ - message: 'Failed to clear app data on logout', - context: { error }, - }); - } + // Remove the standalone stored auth response so no valid refresh + // token is left on the device after logout. + try { + await removeItem('authResponse'); + } catch (error) { + logger.warn({ + message: 'Failed to remove stored auth response on logout', + context: { error }, + }); + } + + // Route EVERY logout (manual, forced by the 401 interceptor, refresh + // credential rejection) through the full app-data reset so a different + // user logging in on the same device never sees the previous user's + // data — and the previous user's queued offline events never replay + // under the new account. The reset service registers its handler in + // the leaf session-cleanup module (avoids a static import cycle). + try { + await runSessionCleanup(); + } catch (error) { + logger.error({ + message: 'Failed to clear app data on logout', + context: { error }, + }); + } + })().finally(() => { + logoutInFlight = null; + }); + + return logoutInFlight; }, refreshAccessToken: async (): Promise => { @@ -211,6 +230,9 @@ const useAuthStore = create()( set({ accessToken: response.access_token, refreshToken: response.refresh_token, + // Keep the persisted access-token expiry current so a later cold + // start can restore the session instantly (see restoreSession). + refreshTokenExpiresOn: new Date(Date.now() + response.expires_in * 1000).getTime().toString(), status: 'signedIn', error: null, }); @@ -260,60 +282,6 @@ const useAuthStore = create()( return false; } }, - hydrate: () => { - try { - const authResponse = getAuth(); - if (authResponse !== null && authResponse.refresh_token) { - // We have stored auth data, try to restore the session - try { - const payload = sanitizeJson(base64.decode(authResponse.id_token!.split('.')[1])); - const profileData = JSON.parse(payload) as ProfileModel; - - set({ - accessToken: authResponse.access_token, - refreshToken: authResponse.refresh_token, - status: 'signedIn', - error: null, - profile: profileData, - userId: profileData.sub, - }); - - Sentry.setUser({ id: profileData.sub, username: profileData.name }); - - logger.info({ - message: 'Auth state hydrated from storage, token refresh will be scheduled by onRehydrateStorage', - }); - - // Note: Token refresh scheduling is handled by onRehydrateStorage to avoid duplicate refreshes - } catch (parseError) { - // Token parsing failed, but we have a refresh token - try to refresh - logger.warn({ - message: 'Failed to parse stored token, refresh will be attempted by onRehydrateStorage', - context: { error: parseError instanceof Error ? parseError.message : String(parseError) }, - }); - - set({ - refreshToken: authResponse.refresh_token, - status: 'loading', - }); - - // Note: Token refresh is handled by onRehydrateStorage to avoid duplicate refreshes - } - } else { - logger.info({ - message: 'No stored auth data found, user needs to login', - }); - get().logout(); - } - } catch (e) { - logger.error({ - message: 'Failed to hydrate auth state', - context: { error: e instanceof Error ? e.message : String(e) }, - }); - // Don't logout here - let the user try to use the app - // and handle auth errors via the axios interceptor - } - }, isAuthenticated: (): boolean => { return get().status === 'signedIn' && get().accessToken !== null; }, @@ -365,27 +333,7 @@ const useAuthStore = create()( // Defer execution to ensure useAuthStore is fully initialized setTimeout(() => { - // status/error are no longer persisted, so a stored refresh token is - // the only signal that a session existed — always try to restore it. - if (state && state.refreshToken) { - logger.info({ - message: 'Found refresh token in storage, attempting to restore session', - context: { hasAccessToken: !!state.accessToken }, - }); - - // Clear any existing refresh timer before scheduling a new one - const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; - if (existingTimeoutId !== null) { - clearTimeout(existingTimeoutId); - } - // Set status to loading while we try to refresh - useAuthStore.setState({ status: 'loading' }); - - const timeoutId = setTimeout(() => { - useAuthStore.getState().refreshAccessToken(); - }, 2000); - useAuthStore.setState({ refreshTimeoutId: timeoutId }); - } + restoreSession(state); }, 0); }; }, @@ -397,6 +345,110 @@ const sanitizeJson = (json: string) => { return json.replace(/[\u0000]+/g, ''); }; +// Treat an access token expiring within this window as already expired so we +// never restore "signedIn with a valid token" on a token about to lapse. +const ACCESS_TOKEN_EXPIRY_SKEW_MS = 60 * 1000; + +/** + * Resolve when the stored access token expires (epoch ms). Prefers the token's + * own `exp` claim; falls back to the persisted expiry timestamp captured at + * login/refresh time (`refreshTokenExpiresOn` -- historical name, it holds the + * access-token expiry). Returns null when neither is usable. + */ +const getAccessTokenExpiryMs = (accessToken: string | null | undefined, storedExpiresOn: string | null | undefined): number | null => { + if (accessToken) { + const jwtExpiry = getJwtExpiryMs(accessToken); + if (jwtExpiry !== null) { + return jwtExpiry; + } + } + if (storedExpiresOn) { + const parsed = Number(storedExpiresOn); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return null; +}; + +type PersistedSession = Pick; + +/** + * Cold-start session restore, invoked once after zustand rehydrates the + * persisted auth state. Exported for tests. + * + * - Valid access token: signedIn immediately (no network, no artificial delay) + * with a proactive refresh scheduled in the background before expiry. + * - Expired/undecodable access token + refresh token: signedIn optimistically -- + * any API call that hits a 401 rides the interceptor's single-flight refresh -- + * while an immediate background refresh runs. + * - Refresh token only: nothing to call the API with, so show 'loading' while + * refreshing immediately (no fixed delay). + * - Transient refresh failures keep the session (refreshAccessToken retries + * every 30s); only a definitive credential rejection (400/401 from + * /connect/token) logs the user out. + */ +export const restoreSession = (state: PersistedSession | undefined): void => { + if (!state || !state.refreshToken) { + // No stored session -- leave status as-is; the tab layout redirects to login. + return; + } + + // Clear any existing refresh timer before scheduling a new one + const existingTimeoutId = useAuthStore.getState().refreshTimeoutId; + if (existingTimeoutId !== null) { + clearTimeout(existingTimeoutId); + } + + if (state.profile?.sub) { + Sentry.setUser({ id: state.profile.sub, username: state.profile.name }); + } + + // The persist middleware has already merged these into the store; re-applying + // them keeps this function self-contained, so the scheduled refresh below can + // never observe a store without the credentials it was told to restore. + const restoredTokens = { + accessToken: state.accessToken, + refreshToken: state.refreshToken, + refreshTokenExpiresOn: state.refreshTokenExpiresOn, + }; + + const expiresOn = getAccessTokenExpiryMs(state.accessToken, state.refreshTokenExpiresOn); + const now = Date.now(); + + if (state.accessToken && expiresOn !== null && expiresOn - now > ACCESS_TOKEN_EXPIRY_SKEW_MS) { + // Access token still valid -- restore instantly and refresh in the + // background shortly before it expires (1 minute early, like the login path). + const refreshDelayMs = Math.max(expiresOn - now - 60 * 1000, 1000); + logger.info({ + message: 'Restored session from storage with valid access token', + context: { refreshDelayMs }, + }); + const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), refreshDelayMs); + useAuthStore.setState({ ...restoredTokens, status: 'signedIn', error: null, refreshTimeoutId: timeoutId }); + return; + } + + if (state.accessToken) { + // Access token expired (or expiry unknown) but a refresh token exists. Stay + // signed in optimistically: the 401 interceptor refreshes on demand, and an + // offline cold start keeps the session instead of bouncing to login. + logger.info({ + message: 'Restored session from storage with expired access token, refreshing in background', + }); + const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), 0); + useAuthStore.setState({ ...restoredTokens, status: 'signedIn', error: null, refreshTimeoutId: timeoutId }); + return; + } + + // Refresh token only -- refresh immediately, no artificial delay. + logger.info({ + message: 'Found refresh token in storage without access token, attempting to restore session', + }); + const timeoutId = setTimeout(() => useAuthStore.getState().refreshAccessToken(), 0); + useAuthStore.setState({ ...restoredTokens, status: 'loading', refreshTimeoutId: timeoutId }); +}; + // Keep the API cache scoped to whoever is signed in. Cache keys embed this identity, so stamping it // here means a second user on the same device can never be served the first user's cached rosters, // units or contacts -- and signing out drops the scope so nothing leaks into an anonymous session. diff --git a/src/stores/calls/__tests__/store.test.ts b/src/stores/calls/__tests__/store.test.ts index 6cf915df..874886de 100644 --- a/src/stores/calls/__tests__/store.test.ts +++ b/src/stores/calls/__tests__/store.test.ts @@ -43,6 +43,7 @@ describe('useCallsStore', () => { callPriorities: [], callTypes: [], isLoading: false, + isInitialized: false, error: null, }); }); @@ -186,5 +187,141 @@ describe('useCallsStore', () => { expect(mockGetCallPriorities).toHaveBeenCalledTimes(1); expect(mockGetCallTypes).toHaveBeenCalledTimes(1); }); + + it('clears isLoading and leaves isInitialized false when a fetch throws, so init can be retried', async () => { + mockGetCalls.mockRejectedValue(new Error('Network down')); + + const { result } = renderHook(() => useCallsStore()); + + await act(async () => { + await result.current.init(); + }); + + await waitFor(() => { + // isLoading must clear or the guard at the top of init() blocks every retry forever + expect(result.current.isLoading).toBe(false); + // isInitialized must stay false so the TabLayout retry loop can call init() again + expect(result.current.isInitialized).toBe(false); + expect(result.current.error).toBe('Failed to initialize calls'); + }); + }); + + it('allows a subsequent init() to succeed after an initial failure', async () => { + mockGetCalls.mockRejectedValueOnce(new Error('Network down')); + + const { result } = renderHook(() => useCallsStore()); + + await act(async () => { + await result.current.init(); + }); + expect(result.current.isInitialized).toBe(false); + + const mockCallsData = [{ Id: '1', Name: 'Test Call' }]; + mockGetCalls.mockResolvedValue({ Data: mockCallsData } as any); + mockGetCallPriorities.mockResolvedValue({ Data: [] } as any); + mockGetCallTypes.mockResolvedValue({ Data: [] } as any); + + await act(async () => { + await result.current.init(); + }); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + expect(result.current.calls).toEqual(mockCallsData); + expect(result.current.isLoading).toBe(false); + }); + }); + }); + + describe('fetchCalls', () => { + it('sets isLoading while fetching when there is no data yet', async () => { + let resolveCalls: (value: any) => void = () => {}; + mockGetCalls.mockReturnValue(new Promise((resolve) => (resolveCalls = resolve)) as any); + + const { result } = renderHook(() => useCallsStore()); + + let pending: Promise; + act(() => { + pending = result.current.fetchCalls(); + }); + + expect(useCallsStore.getState().isLoading).toBe(true); + + await act(async () => { + resolveCalls({ Data: [{ CallId: '1' }] }); + await pending!; + }); + + expect(useCallsStore.getState().isLoading).toBe(false); + }); + + it('does NOT set isLoading when calls are already present (stale-while-revalidate)', async () => { + useCallsStore.setState({ calls: [{ CallId: 'existing' }] as any }); + + let resolveCalls: (value: any) => void = () => {}; + mockGetCalls.mockReturnValue(new Promise((resolve) => (resolveCalls = resolve)) as any); + + const { result } = renderHook(() => useCallsStore()); + + let pending: Promise; + act(() => { + pending = result.current.fetchCalls(true); + }); + + // The stale list stays on screen instead of being replaced by a full-screen loader + expect(useCallsStore.getState().isLoading).toBe(false); + expect(useCallsStore.getState().calls).toHaveLength(1); + + await act(async () => { + resolveCalls({ Data: [{ CallId: 'fresh' }] }); + await pending!; + }); + + expect(useCallsStore.getState().calls).toEqual([{ CallId: 'fresh' }]); + }); + + it('defaults forceRefresh to false and forwards an explicit true to getCalls', async () => { + mockGetCalls.mockResolvedValue({ Data: [] } as any); + + const { result } = renderHook(() => useCallsStore()); + + await act(async () => { + await result.current.fetchCalls(); + }); + expect(mockGetCalls).toHaveBeenLastCalledWith(false); + + await act(async () => { + await result.current.fetchCalls(true); + }); + expect(mockGetCalls).toHaveBeenLastCalledWith(true); + }); + + it('records an error and clears isLoading when the fetch fails', async () => { + mockGetCalls.mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useCallsStore()); + + await act(async () => { + await result.current.fetchCalls(); + }); + + await waitFor(() => { + expect(result.current.error).toBe('Failed to fetch calls'); + expect(result.current.isLoading).toBe(false); + }); + }); + + it('clears a previous error on a new fetch', async () => { + useCallsStore.setState({ error: 'Failed to fetch calls' }); + mockGetCalls.mockResolvedValue({ Data: [{ CallId: '1' }] } as any); + + const { result } = renderHook(() => useCallsStore()); + + await act(async () => { + await result.current.fetchCalls(true); + }); + + expect(result.current.error).toBeNull(); + }); }); }); diff --git a/src/stores/calls/store.ts b/src/stores/calls/store.ts index 4d8866a6..56da2694 100644 --- a/src/stores/calls/store.ts +++ b/src/stores/calls/store.ts @@ -26,7 +26,7 @@ interface CallsState { isCallFormDataLoaded: boolean; error: string | null; lastFetchedAt: number; - fetchCalls: () => Promise; + fetchCalls: (forceRefresh?: boolean) => Promise; fetchCallPriorities: () => Promise; fetchCallTypes: () => Promise; fetchCallFormData: () => Promise; @@ -53,22 +53,35 @@ export const useCallsStore = create((set, get) => ({ return; } set({ isLoading: true, error: null }); - const callsResponse = await getCalls(); - const callPrioritiesResponse = await getCallPriorities(); - const callTypesResponse = await getCallTypes(); - set({ - calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [], - callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [], - callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [], - isLoading: false, - isInitialized: true, - lastFetchedAt: Date.now(), - }); + try { + const callsResponse = await getCalls(); + const callPrioritiesResponse = await getCallPriorities(); + const callTypesResponse = await getCallTypes(); + set({ + calls: Array.isArray(callsResponse.Data) ? callsResponse.Data : [], + callPriorities: Array.isArray(callPrioritiesResponse.Data) ? callPrioritiesResponse.Data : [], + callTypes: Array.isArray(callTypesResponse.Data) ? callTypesResponse.Data : [], + isLoading: false, + isInitialized: true, + lastFetchedAt: Date.now(), + }); + } catch (error) { + // isInitialized stays false so the TabLayout retry loop can call init() again; + // isLoading must clear or the guard above would block every retry forever. + logger.error({ message: 'Failed to initialize calls store', context: { error } }); + set({ error: 'Failed to initialize calls', isLoading: false }); + } }, - fetchCalls: async () => { - set({ isLoading: true, error: null }); + fetchCalls: async (forceRefresh = false) => { + // Stale-while-revalidate: only show the blocking loader when there is nothing + // to display yet — refreshes with data on screen happen in the background. + if (get().calls.length === 0) { + set({ isLoading: true, error: null }); + } else { + set({ error: null }); + } try { - const response = await getCalls(); + const response = await getCalls(forceRefresh); const newCalls = Array.isArray(response.Data) ? response.Data : []; // Evict dispatches for calls no longer in the active list to prevent unbounded memory growth diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index b7e46ea8..febbc001 100644 --- a/src/stores/chat/__tests__/hub-invoke-args.test.ts +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -191,7 +191,9 @@ describe('incoming message normalization', () => { }); it('keeps existing reactions when a later payload omits them', () => { - useChatStore.getState().handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] }); + useChatStore + .getState() + .handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] }); useChatStore.getState().handleMessageEdited({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi (edited)', SentOn: new Date(0).toISOString() }); const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0]; @@ -236,3 +238,65 @@ describe('chat typing events', () => { expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.userId).toBe('user-2'); }); }); + +describe('chat fetch failures stay out of Sentry', () => { + const chatApi = require('@/api/chat/chat'); + const { logger } = require('@/lib/logging'); + + beforeEach(() => { + jest.clearAllMocks(); + useChatStore.setState({ messagesByChannel: {}, channels: [], loadingMessagesByChannel: {} }); + }); + + afterEach(() => { + chatApi.getChannels.mockResolvedValue({ Data: [] }); + chatApi.getMessages.mockResolvedValue({ Data: [] }); + }); + + afterAll(() => { + // reset() clears the store's typing/outbox timers so Jest can exit cleanly. + useChatStore.getState().reset(); + }); + + it('logs a channel fetch failure at warn, mirroring the outbox send path', async () => { + chatApi.getChannels.mockRejectedValue(new Error('offline')); + + await useChatStore.getState().fetchChannels(); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to fetch channels' })); + expect(logger.error).not.toHaveBeenCalled(); + expect(useChatStore.getState().isLoadingChannels).toBe(false); + }); + + it('logs an initial message load failure at warn', async () => { + chatApi.getMessages.mockRejectedValue(new Error('offline')); + + await useChatStore.getState().loadInitialMessages('channel-1'); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to load messages' })); + expect(logger.error).not.toHaveBeenCalled(); + expect(useChatStore.getState().loadingMessagesByChannel['channel-1']).toBe(false); + }); + + it('logs an older-message load failure at warn', async () => { + useChatStore.setState({ + messagesByChannel: { 'channel-1': [{ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 5, SentOn: new Date().toISOString(), Reactions: [], Attachments: [] } as any] }, + hasMoreByChannel: { 'channel-1': true }, + }); + chatApi.getMessages.mockRejectedValue(new Error('offline')); + + await useChatStore.getState().loadOlderMessages('channel-1'); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: failed to load older messages' })); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('logs a delta sync failure at warn', async () => { + chatApi.getMessagesAfter = jest.fn().mockRejectedValue(new Error('offline')); + + await useChatStore.getState().loadNewerMessages('channel-1'); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'chat: delta sync failed' })); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 0ad03e4c..2ead2950 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -290,7 +290,7 @@ export const useChatStore = create()( const response = await chatApi.getChannels(unitId); set({ channels: response.Data ?? [], isLoadingChannels: false }); } catch (error) { - logger.error({ message: 'chat: failed to fetch channels', context: { error } }); + logger.warn({ message: 'chat: failed to fetch channels', context: { error } }); set({ isLoadingChannels: false }); } }, @@ -321,7 +321,7 @@ export const useChatStore = create()( }; }); } catch (error) { - logger.error({ message: 'chat: failed to load messages', context: { error, channelId } }); + logger.warn({ message: 'chat: failed to load messages', context: { error, channelId } }); set((state) => ({ loadingMessagesByChannel: { ...state.loadingMessagesByChannel, [channelId]: false } })); } }, @@ -349,7 +349,7 @@ export const useChatStore = create()( }; }); } catch (error) { - logger.error({ message: 'chat: failed to load older messages', context: { error, channelId } }); + logger.warn({ message: 'chat: failed to load older messages', context: { error, channelId } }); set((s) => ({ loadingMessagesByChannel: { ...s.loadingMessagesByChannel, [channelId]: false } })); } }, @@ -370,7 +370,7 @@ export const useChatStore = create()( after = highestRealSeq(get().messagesByChannel[channelId]); } } catch (error) { - logger.error({ message: 'chat: delta sync failed', context: { error, channelId } }); + logger.warn({ message: 'chat: delta sync failed', context: { error, channelId } }); } }, diff --git a/src/stores/check-in-timers/__tests__/store.test.ts b/src/stores/check-in-timers/__tests__/store.test.ts index 1b998023..f3399c05 100644 --- a/src/stores/check-in-timers/__tests__/store.test.ts +++ b/src/stores/check-in-timers/__tests__/store.test.ts @@ -22,6 +22,7 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals'; import { act, renderHook, waitFor } from '@testing-library/react-native'; import { getCheckInHistory, getTimerStatuses, getTimersForCall, performCheckIn } from '@/api/check-in-timers/check-in-timers'; +import { logger } from '@/lib/logging'; import { useCheckInTimerStore } from '../store'; jest.mock('@/api/check-in-timers/check-in-timers'); @@ -108,6 +109,19 @@ describe('useCheckInTimerStore', () => { expect(result.current.isLoadingStatuses).toBe(false); }); }); + + it('should log a poll failure at warn so a 30s poll cannot flood Sentry', async () => { + mockGetTimerStatuses.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useCheckInTimerStore()); + + await act(async () => { + await result.current.fetchTimerStatuses(1); + }); + + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to fetch timer statuses' })); + expect(logger.error).not.toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to fetch timer statuses' })); + }); }); describe('performCheckIn', () => { diff --git a/src/stores/check-in-timers/store.ts b/src/stores/check-in-timers/store.ts index c0d555ab..f68d38e2 100644 --- a/src/stores/check-in-timers/store.ts +++ b/src/stores/check-in-timers/store.ts @@ -57,7 +57,9 @@ export const useCheckInTimerStore = create((set, get) => ({ set({ timerStatuses: sorted, isLoadingStatuses: false }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to fetch timer statuses'; - logger.error({ message: 'Failed to fetch timer statuses', context: { error, callId } }); + // Polled every 30s while a call is open — a transient failure here is + // retried on the next tick and must not flood Sentry. + logger.warn({ message: 'Failed to fetch timer statuses', context: { error, callId } }); set({ statusError: message, isLoadingStatuses: false }); } }, diff --git a/src/stores/feature-flags/__tests__/store.test.ts b/src/stores/feature-flags/__tests__/store.test.ts index 322b2889..28bddc77 100644 --- a/src/stores/feature-flags/__tests__/store.test.ts +++ b/src/stores/feature-flags/__tests__/store.test.ts @@ -41,6 +41,7 @@ jest.mock('../../security/store', () => ({ })); const { getAllFeatureFlags } = require('@/api/feature-flags/feature-flags'); +const { logger } = require('@/lib/logging'); const useAuthStore = require('../../auth/store').default; const { securityStore } = require('../../security/store'); @@ -94,6 +95,19 @@ describe('Feature Flags Store', () => { expect(state.error).toBe('network down'); }); + it('should log a fetch failure at warn, not error, so it never reaches Sentry', async () => { + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to fetch feature flags', + }) + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + it('should clear flags from a different department before fetching so a failed fetch cannot reuse them', async () => { featureFlagsStore.setState({ flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, @@ -211,6 +225,29 @@ describe('Feature Flags Store', () => { }); }); + describe('persistence', () => { + it('should persist flags, isLoaded and identityKey but never the transient error', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + error: 'transient failure', + }); + + const partialize = (featureFlagsStore as any).persist.getOptions().partialize; + const persisted = partialize(featureFlagsStore.getState()); + + // isLoaded is kept on purpose: rehydrating without it leaves gated screens + // on 'unknown' instead of resolving fail-closed. + expect(persisted).toEqual({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + expect(persisted).not.toHaveProperty('error'); + }); + }); + describe('isEnabled', () => { it('should return the flag state when present and the default when missing', () => { featureFlagsStore.setState({ diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts index 9bdc5909..0e35e3c3 100644 --- a/src/stores/feature-flags/store.ts +++ b/src/stores/feature-flags/store.ts @@ -84,7 +84,9 @@ export const featureFlagsStore = create()( // check above already cleared them if they belonged to a different identity. Marking // isLoaded resolves flags with no persisted entry fail-closed (disabled) instead of // leaving consumers waiting on 'unknown' forever. - logger.error({ + // Transient/recoverable: persisted flags keep gating stable and the next + // fetch repairs it, so this must not report to Sentry. + logger.warn({ message: 'Failed to fetch feature flags', context: { error }, }); @@ -96,6 +98,14 @@ export const featureFlagsStore = create()( { name: 'feature-flags-storage', storage: createJSONStorage(() => zustandStorage), + // isLoaded is persisted deliberately: a rehydrated identity resolves flags + // with no persisted entry fail-closed instead of leaving gated screens on + // 'unknown' forever. error is transient and must not survive a restart. + partialize: (state) => ({ + flags: state.flags, + isLoaded: state.isLoaded, + identityKey: state.identityKey, + }), } ) ); diff --git a/src/stores/offline-queue/__tests__/store.test.ts b/src/stores/offline-queue/__tests__/store.test.ts index bf693332..ae1e96b4 100644 --- a/src/stores/offline-queue/__tests__/store.test.ts +++ b/src/stores/offline-queue/__tests__/store.test.ts @@ -1,5 +1,5 @@ import { QueuedEventStatus, QueuedEventType } from '@/models/offline-queue/queued-event'; -import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { setOfflineQueueActivityListener, useOfflineQueueStore } from '@/stores/offline-queue/store'; // Mock NetInfo jest.mock('@react-native-community/netinfo', () => ({ @@ -55,25 +55,25 @@ describe('OfflineQueueStore', () => { failedEvents: 0, completedEvents: 0, }); - + // Reset event ID counter and mock eventIdCounter = 0; mockGenerateEventId.mockImplementation(() => `test-event-id-${++eventIdCounter}`); - + jest.clearAllMocks(); }); describe('addEvent', () => { it('should add a new event to the queue', () => { const store = useOfflineQueueStore.getState(); - + const eventId = store.addEvent(QueuedEventType.UNIT_STATUS, { unitId: 'unit-1', statusType: 'available', }); expect(eventId).toBe('test-event-id-1'); - + const state = useOfflineQueueStore.getState(); expect(state.queuedEvents).toHaveLength(1); expect(state.queuedEvents[0]).toMatchObject({ @@ -92,7 +92,7 @@ describe('OfflineQueueStore', () => { it('should add event with custom max retries', () => { const store = useOfflineQueueStore.getState(); - + store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' }, 5); const state = useOfflineQueueStore.getState(); @@ -102,7 +102,7 @@ describe('OfflineQueueStore', () => { describe('updateEventStatus', () => { let eventId: string; - + beforeEach(() => { // Add an event first const store = useOfflineQueueStore.getState(); @@ -111,7 +111,7 @@ describe('OfflineQueueStore', () => { it('should update event status to completed', () => { const store = useOfflineQueueStore.getState(); - + store.updateEventStatus(eventId, QueuedEventStatus.COMPLETED); const state = useOfflineQueueStore.getState(); @@ -121,7 +121,7 @@ describe('OfflineQueueStore', () => { it('should update event status to failed and increment retry count', () => { const store = useOfflineQueueStore.getState(); - + store.updateEventStatus(eventId, QueuedEventStatus.FAILED, 'Network error'); const state = useOfflineQueueStore.getState(); @@ -134,7 +134,7 @@ describe('OfflineQueueStore', () => { it('should not set nextRetryAt if max retries exceeded', () => { const store = useOfflineQueueStore.getState(); - + // Set retry count to max store.updateEventStatus(eventId, QueuedEventStatus.FAILED); store.updateEventStatus(eventId, QueuedEventStatus.FAILED); @@ -147,7 +147,7 @@ describe('OfflineQueueStore', () => { describe('removeEvent', () => { let eventId: string; - + beforeEach(() => { const store = useOfflineQueueStore.getState(); eventId = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' }); @@ -155,7 +155,7 @@ describe('OfflineQueueStore', () => { it('should remove event from queue', () => { const store = useOfflineQueueStore.getState(); - + store.removeEvent(eventId); const state = useOfflineQueueStore.getState(); @@ -165,7 +165,7 @@ describe('OfflineQueueStore', () => { describe('getEventById', () => { let eventId: string; - + beforeEach(() => { const store = useOfflineQueueStore.getState(); eventId = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data' }); @@ -173,7 +173,7 @@ describe('OfflineQueueStore', () => { it('should return event by ID', () => { const store = useOfflineQueueStore.getState(); - + const event = store.getEventById(eventId); expect(event).toBeDefined(); @@ -182,7 +182,7 @@ describe('OfflineQueueStore', () => { it('should return undefined for non-existent ID', () => { const store = useOfflineQueueStore.getState(); - + const event = store.getEventById('non-existent'); expect(event).toBeUndefined(); @@ -198,7 +198,7 @@ describe('OfflineQueueStore', () => { it('should return events of specified type', () => { const store = useOfflineQueueStore.getState(); - + const events = store.getEventsByType(QueuedEventType.UNIT_STATUS); expect(events).toHaveLength(1); @@ -209,7 +209,7 @@ describe('OfflineQueueStore', () => { describe('getPendingEvents', () => { let eventId1: string; let eventId2: string; - + beforeEach(() => { const store = useOfflineQueueStore.getState(); eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' }); @@ -218,7 +218,7 @@ describe('OfflineQueueStore', () => { it('should return events with pending status', () => { const store = useOfflineQueueStore.getState(); - + const events = store.getPendingEvents(); expect(events).toHaveLength(2); @@ -229,7 +229,7 @@ describe('OfflineQueueStore', () => { it('should include failed events ready for retry', () => { const store = useOfflineQueueStore.getState(); - + // Mark one event as failed with retry time in the past store.updateEventStatus(eventId1, QueuedEventStatus.FAILED); const state = useOfflineQueueStore.getState(); @@ -243,7 +243,7 @@ describe('OfflineQueueStore', () => { it('should exclude failed events not ready for retry', () => { const store = useOfflineQueueStore.getState(); - + // Mark one event as failed with retry time in the future store.updateEventStatus(eventId1, QueuedEventStatus.FAILED); const state = useOfflineQueueStore.getState(); @@ -259,7 +259,7 @@ describe('OfflineQueueStore', () => { describe('getFailedEvents', () => { let eventId1: string; let eventId2: string; - + beforeEach(() => { const store = useOfflineQueueStore.getState(); eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' }); @@ -268,7 +268,7 @@ describe('OfflineQueueStore', () => { it('should return events that have exceeded max retries', () => { const store = useOfflineQueueStore.getState(); - + // Fail first event beyond max retries store.updateEventStatus(eventId1, QueuedEventStatus.FAILED); store.updateEventStatus(eventId1, QueuedEventStatus.FAILED); @@ -285,7 +285,7 @@ describe('OfflineQueueStore', () => { describe('clearCompletedEvents', () => { let eventId1: string; let eventId2: string; - + beforeEach(() => { const store = useOfflineQueueStore.getState(); eventId1 = store.addEvent(QueuedEventType.UNIT_STATUS, { test: 'data1' }); @@ -295,7 +295,7 @@ describe('OfflineQueueStore', () => { it('should remove completed events', () => { const store = useOfflineQueueStore.getState(); - + store.clearCompletedEvents(); const state = useOfflineQueueStore.getState(); @@ -313,7 +313,7 @@ describe('OfflineQueueStore', () => { it('should clear all events and reset counters', () => { const store = useOfflineQueueStore.getState(); - + store.clearAllEvents(); const state = useOfflineQueueStore.getState(); @@ -335,7 +335,7 @@ describe('OfflineQueueStore', () => { it('should reset failed event to pending', () => { const store = useOfflineQueueStore.getState(); - + store.retryEvent('test-event-id'); const state = useOfflineQueueStore.getState(); @@ -355,7 +355,7 @@ describe('OfflineQueueStore', () => { it('should reset all failed events to pending', () => { const store = useOfflineQueueStore.getState(); - + store.retryAllFailedEvents(); const state = useOfflineQueueStore.getState(); @@ -372,7 +372,7 @@ describe('OfflineQueueStore', () => { describe('network state management', () => { it('should update network state', () => { const store = useOfflineQueueStore.getState(); - + store._setNetworkState(false, false); const state = useOfflineQueueStore.getState(); @@ -382,7 +382,7 @@ describe('OfflineQueueStore', () => { it('should update processing state', () => { const store = useOfflineQueueStore.getState(); - + store._setProcessing(true, 'event-123'); const state = useOfflineQueueStore.getState(); @@ -390,4 +390,109 @@ describe('OfflineQueueStore', () => { expect(state.processingEventId).toBe('event-123'); }); }); + + describe('pruneFailedEvents', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + + const makeFailedEvent = (id: string, ageDays: number) => ({ + id, + type: QueuedEventType.UNIT_STATUS, + status: QueuedEventStatus.FAILED, + data: {}, + retryCount: 3, + maxRetries: 3, + createdAt: Date.now() - ageDays * DAY_MS, + lastAttemptAt: Date.now() - ageDays * DAY_MS, + }); + + it('should evict permanently failed events older than the retention window', () => { + useOfflineQueueStore.setState({ + queuedEvents: [makeFailedEvent('old', 8), makeFailedEvent('recent', 1)] as any, + }); + + useOfflineQueueStore.getState().pruneFailedEvents(); + + const ids = useOfflineQueueStore.getState().queuedEvents.map((event) => event.id); + expect(ids).toEqual(['recent']); + }); + + it('should cap retained failed events at the newest 50', () => { + const events = Array.from({ length: 60 }, (_, index) => makeFailedEvent(`failed-${index}`, index / 24)); + useOfflineQueueStore.setState({ queuedEvents: events as any }); + + useOfflineQueueStore.getState().pruneFailedEvents(); + + expect(useOfflineQueueStore.getState().queuedEvents).toHaveLength(50); + }); + + it('should never evict events that still have retries left', () => { + const retryable = { ...makeFailedEvent('retryable', 30), retryCount: 1, maxRetries: 3 }; + useOfflineQueueStore.setState({ queuedEvents: [retryable] as any }); + + useOfflineQueueStore.getState().pruneFailedEvents(); + + expect(useOfflineQueueStore.getState().queuedEvents).toHaveLength(1); + }); + + it('should leave the lifetime stat counters alone', () => { + useOfflineQueueStore.setState({ + queuedEvents: [makeFailedEvent('old', 8)] as any, + failedEvents: 7, + totalEvents: 9, + }); + + useOfflineQueueStore.getState().pruneFailedEvents(); + + const state = useOfflineQueueStore.getState(); + expect(state.failedEvents).toBe(7); + expect(state.totalEvents).toBe(9); + }); + }); + + describe('queue activity listener', () => { + afterEach(() => { + setOfflineQueueActivityListener(null); + }); + + it('should notify on enqueue so a stopped processing timer restarts', () => { + const listener = jest.fn(); + setOfflineQueueActivityListener(listener); + + useOfflineQueueStore.getState().addEvent(QueuedEventType.UNIT_STATUS, { unitId: 'unit-1' }); + + expect(listener).toHaveBeenCalled(); + }); + + it('should notify when the device comes back online', () => { + useOfflineQueueStore.setState({ isConnected: false, isNetworkReachable: false }); + const listener = jest.fn(); + setOfflineQueueActivityListener(listener); + + useOfflineQueueStore.getState()._setNetworkState(true, true); + + expect(listener).toHaveBeenCalled(); + }); + + it('should not notify while already online', () => { + useOfflineQueueStore.setState({ isConnected: true, isNetworkReachable: true }); + const listener = jest.fn(); + setOfflineQueueActivityListener(listener); + + useOfflineQueueStore.getState()._setNetworkState(true, true); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('should notify when failed events are queued for retry', () => { + useOfflineQueueStore.setState({ + queuedEvents: [{ id: 'f1', type: QueuedEventType.UNIT_STATUS, status: QueuedEventStatus.FAILED, data: {}, retryCount: 3, maxRetries: 3, createdAt: Date.now() }] as any, + }); + const listener = jest.fn(); + setOfflineQueueActivityListener(listener); + + useOfflineQueueStore.getState().retryAllFailedEvents(); + + expect(listener).toHaveBeenCalled(); + }); + }); }); diff --git a/src/stores/offline-queue/store.ts b/src/stores/offline-queue/store.ts index 1f305a7c..dc93e11c 100644 --- a/src/stores/offline-queue/store.ts +++ b/src/stores/offline-queue/store.ts @@ -31,6 +31,7 @@ interface OfflineQueueState { getEventsByType: (type: QueuedEventType) => QueuedEvent[]; getPendingEvents: () => QueuedEvent[]; getFailedEvents: () => QueuedEvent[]; + pruneFailedEvents: () => void; clearCompletedEvents: () => void; clearAllEvents: () => void; retryEvent: (eventId: string) => void; @@ -44,9 +45,27 @@ interface OfflineQueueState { const DEFAULT_MAX_RETRIES = 3; const RETRY_DELAY_BASE = 1000; // 1 second base delay +// Permanently failed events (retries exhausted) are kept only long enough to be +// surfaced/retried by the user. Without a bound they accumulate in MMKV forever. +const FAILED_EVENT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const MAX_RETAINED_FAILED_EVENTS = 50; + // Module-level handle so initialize() never stacks duplicate NetInfo listeners. let netInfoUnsubscribe: (() => void) | null = null; +// Set by the offline event manager so the queue can wake its processing timer +// when work arrives. A direct import would close an offline-queue <-> manager +// cycle, so the dependency points one way and the manager registers itself. +let queueActivityListener: (() => void) | null = null; + +export const setOfflineQueueActivityListener = (listener: (() => void) | null): void => { + queueActivityListener = listener; +}; + +const isPermanentlyFailed = (event: QueuedEvent): boolean => event.status === QueuedEventStatus.FAILED && event.retryCount >= event.maxRetries; + +const failedEventTimestamp = (event: QueuedEvent): number => event.lastAttemptAt ?? event.createdAt; + export const useOfflineQueueStore = create()( persist( (set, get) => ({ @@ -117,6 +136,10 @@ export const useOfflineQueueStore = create()( context: { eventId, type, dataKeys: Object.keys(data) }, }); + // The processing timer stops itself when the queue runs dry, so a new + // event has to wake it back up. + queueActivityListener?.(); + return eventId; }, @@ -184,7 +207,38 @@ export const useOfflineQueueStore = create()( // Get failed events getFailedEvents: () => { - return get().queuedEvents.filter((event) => event.status === QueuedEventStatus.FAILED && event.retryCount >= event.maxRetries); + return get().queuedEvents.filter(isPermanentlyFailed); + }, + + // Evict permanently failed events that are past the retention window, and + // cap the rest at the newest MAX_RETAINED_FAILED_EVENTS. Nothing retries + // these, so without eviction they persist to MMKV forever. Stat counters + // are deliberately left alone — they are lifetime totals, not queue depth. + pruneFailedEvents: () => { + const failed = get().queuedEvents.filter(isPermanentlyFailed); + if (failed.length === 0) { + return; + } + + const now = Date.now(); + const retained = failed + .filter((event) => now - failedEventTimestamp(event) < FAILED_EVENT_TTL_MS) + .sort((a, b) => failedEventTimestamp(b) - failedEventTimestamp(a)) + .slice(0, MAX_RETAINED_FAILED_EVENTS); + + if (retained.length === failed.length) { + return; + } + + const retainedIds = new Set(retained.map((event) => event.id)); + set((state) => ({ + queuedEvents: state.queuedEvents.filter((event) => !isPermanentlyFailed(event) || retainedIds.has(event.id)), + })); + + logger.info({ + message: 'Evicted permanently failed events from offline queue', + context: { evicted: failed.length - retained.length, retained: retained.length }, + }); }, // Clear completed events @@ -234,6 +288,8 @@ export const useOfflineQueueStore = create()( message: 'Event marked for retry', context: { eventId }, }); + + queueActivityListener?.(); }, // Retry all failed events @@ -255,11 +311,21 @@ export const useOfflineQueueStore = create()( logger.info({ message: 'All failed events marked for retry', }); + + queueActivityListener?.(); }, // Internal actions _setNetworkState: (isConnected: boolean, isReachable: boolean) => { + const previous = get(); + const wasOnline = previous.isConnected && previous.isNetworkReachable; set({ isConnected, isNetworkReachable: isReachable }); + + // Coming back online is the moment a queue parked while offline can + // drain, so restart the processing timer if it stopped. + if (!wasOnline && isConnected && isReachable) { + queueActivityListener?.(); + } }, _setProcessing: (isProcessing: boolean, eventId?: string) => { @@ -289,6 +355,10 @@ export const useOfflineQueueStore = create()( queuedEvents: current.queuedEvents.map((event) => (event.status === QueuedEventStatus.PROCESSING ? { ...event, status: QueuedEventStatus.PENDING } : event)), })); } + + // Drop permanently failed events that aged out while the app was + // closed, so a long-dead event never rides along forever. + state?.pruneFailedEvents?.(); }; }, } diff --git a/src/stores/security/__tests__/store.test.ts b/src/stores/security/__tests__/store.test.ts index f06ca6f8..ec027778 100644 --- a/src/stores/security/__tests__/store.test.ts +++ b/src/stores/security/__tests__/store.test.ts @@ -37,11 +37,27 @@ jest.mock('react-native', () => ({ }, })); +jest.mock('@/lib/logging', () => ({ + logger: { + info: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@/utils/network', () => ({ + isNetworkError: jest.fn(() => false), +})); + // Import after mocks import { securityStore, useSecurityStore } from '../store'; import { getCurrentUsersRights } from '@/api/security/security'; +import { logger } from '@/lib/logging'; +import { isNetworkError } from '@/utils/network'; const mockGetCurrentUsersRights = getCurrentUsersRights as jest.MockedFunction; +const mockIsNetworkError = isNetworkError as jest.MockedFunction; describe('useSecurityStore', () => { const mockRightsData: DepartmentRightsResultData = { @@ -69,6 +85,7 @@ describe('useSecurityStore', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsNetworkError.mockReturnValue(false); // Reset the store before each test act(() => { securityStore.setState({ @@ -100,7 +117,7 @@ describe('useSecurityStore', () => { }); expect(mockGetCurrentUsersRights).toHaveBeenCalledTimes(1); - + // Check that the store was updated const storeState = securityStore.getState(); expect(storeState.rights).toEqual(mockRightsData); @@ -117,11 +134,87 @@ describe('useSecurityStore', () => { }); expect(mockGetCurrentUsersRights).toHaveBeenCalledTimes(1); - + // Store should not be updated on error const storeState = securityStore.getState(); expect(storeState.rights).toBeNull(); }); + + it('logs non-network failures as errors with context', async () => { + mockIsNetworkError.mockReturnValue(false); + mockGetCurrentUsersRights.mockRejectedValue(new Error('boom')); + + const { result } = renderHook(() => useSecurityStore()); + + await act(async () => { + await result.current.getRights(); + }); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to fetch user rights', + }) + ); + expect(securityStore.getState().error).toBe('boom'); + }); + + it('logs transient network failures at warn so they never reach Sentry', async () => { + mockIsNetworkError.mockReturnValue(true); + mockGetCurrentUsersRights.mockRejectedValue(new Error('Network Error')); + + const { result } = renderHook(() => useSecurityStore()); + + await act(async () => { + await result.current.getRights(); + }); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to fetch user rights due to network connectivity', + }) + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('keeps previously loaded rights when a refresh fails', async () => { + act(() => { + securityStore.setState({ rights: mockRightsData }); + }); + mockGetCurrentUsersRights.mockRejectedValue(new Error('boom')); + + const { result } = renderHook(() => useSecurityStore()); + + await act(async () => { + await result.current.getRights(); + }); + + // Init must be able to continue with the rights it already has. + expect(securityStore.getState().rights).toEqual(mockRightsData); + }); + + it('clears a previous error once a fetch succeeds', async () => { + act(() => { + securityStore.setState({ error: 'previous failure' }); + }); + mockGetCurrentUsersRights.mockResolvedValue({ + Data: mockRightsData, + PageSize: 0, + Timestamp: '', + Version: '', + Node: '', + RequestId: '', + Status: '', + Environment: '', + }); + + const { result } = renderHook(() => useSecurityStore()); + + await act(async () => { + await result.current.getRights(); + }); + + expect(securityStore.getState().error).toBeNull(); + }); }); describe('permission checks', () => { @@ -185,7 +278,7 @@ describe('useSecurityStore', () => { it('returns undefined for all permission checks', () => { const { result } = renderHook(() => useSecurityStore()); - + expect(result.current.isUserDepartmentAdmin).toBeUndefined(); expect(result.current.canUserCreateCalls).toBeUndefined(); expect(result.current.canUserCreateNotes).toBeUndefined(); diff --git a/src/stores/security/store.ts b/src/stores/security/store.ts index 6012c1ef..6dca85c9 100644 --- a/src/stores/security/store.ts +++ b/src/stores/security/store.ts @@ -6,6 +6,7 @@ import { cacheManager } from '@/lib/cache/cache-manager'; import { setCacheScope } from '@/lib/cache/cache-scope'; import { logger } from '@/lib/logging'; import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; +import { isNetworkError } from '@/utils/network'; import { zustandStorage } from '../../lib/storage'; @@ -28,10 +29,29 @@ export const securityStore = create()( if (!current || JSON.stringify(current) !== JSON.stringify(response.Data)) { set({ rights: response.Data, + error: null, }); + } else if (_get().error) { + // Clear a previous failure even when the rights themselves are unchanged. + set({ error: null }); } } catch (error) { - // If refresh fails, log out the user + // Rights are refreshed on init and on resume; a failure here leaves the + // previously persisted rights in place rather than blocking startup. + // Transient connectivity failures stay at warn so they never reach + // Sentry — genuine server/parse failures still report as errors. + if (isNetworkError(error)) { + logger.warn({ + message: 'Failed to fetch user rights due to network connectivity', + context: { error }, + }); + } else { + logger.error({ + message: 'Failed to fetch user rights', + context: { error }, + }); + } + set({ error: error instanceof Error ? error.message : 'Failed to fetch user rights' }); } }, }), diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts index 6927a4fe..e884efc6 100644 --- a/src/stores/signalr/__tests__/signalr-store.test.ts +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -39,10 +39,10 @@ jest.mock('../../app/core-store', () => { mockStore.subscribe = jest.fn(); mockStore.setState = jest.fn(); mockStore.destroy = jest.fn(); - + return mockStore; }; - + return { useCoreStore: createMockStore(), }; @@ -58,6 +58,15 @@ jest.mock('../../feature-flags/store', () => ({ }, })); +const mockFetchCalls = jest.fn().mockResolvedValue(undefined); +jest.mock('../../calls/store', () => ({ + useCallsStore: { + getState: jest.fn(() => ({ + fetchCalls: mockFetchCalls, + })), + }, +})); + jest.mock('../../security/store', () => ({ securityStore: { getState: jest.fn(() => ({ @@ -154,7 +163,7 @@ describe('useSignalRStore', () => { expect(typeof result.current.disconnectUpdateHub).toBe('function'); expect(typeof result.current.connectGeolocationHub).toBe('function'); expect(typeof result.current.disconnectGeolocationHub).toBe('function'); - + expect(result.current.isUpdateHubConnected).toBe(false); expect(result.current.isGeolocationHubConnected).toBe(false); expect(result.current.lastUpdateMessage).toBeNull(); @@ -184,9 +193,7 @@ describe('useSignalRStore', () => { }); expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); - expect(result.current.error).toEqual( - new Error('EventingUrl not available in config. Please ensure config is loaded first.') - ); + expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.')); expect(logger.error).toHaveBeenCalledWith({ message: 'EventingUrl not available in config. Please ensure config is loaded first.', @@ -206,12 +213,10 @@ describe('useSignalRStore', () => { }); expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); - expect(result.current.error).toEqual( - new Error('EventingUrl not available in config. Please ensure config is loaded first.') - ); + expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.')); }); - it('should handle connection errors', async () => { + it('should handle connection errors without double-reporting what the service already logged', async () => { const connectionError = new Error('Connection failed'); (signalRService.connectToHubWithEventingUrl as jest.Mock).mockRejectedValue(connectionError); @@ -222,10 +227,58 @@ describe('useSignalRStore', () => { }); expect(result.current.error).toEqual(connectionError); - expect(logger.warn).toHaveBeenCalledWith({ - message: 'Failed to connect to SignalR hubs', - context: { error: connectionError }, + // The service logs the connect failure with hub context; the store must not + // log the same transient failure a second time. + expect(logger.warn).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Failed to connect to SignalR hubs', + }) + ); + }); + + it('should register listeners BEFORE starting the connection so an early onConnected is not dropped', async () => { + const registeredBeforeConnect: string[] = []; + let connectStarted = false; + + (signalRService.on as jest.Mock).mockImplementation((event: string) => { + if (!connectStarted) { + registeredBeforeConnect.push(event); + } + }); + (signalRService.connectToHubWithEventingUrl as jest.Mock).mockImplementation(async () => { + connectStarted = true; }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + // onConnected is the flag-setting listener the race dropped. + expect(registeredBeforeConnect).toContain('onConnected'); + expect(registeredBeforeConnect).toContain('callAdded'); + expect(registeredBeforeConnect).toContain('__hubReconnected:eventingHub'); + }); + + it('should set isUpdateHubConnected when onConnected fires during the connect call', async () => { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + // Simulate the server raising onConnected while connectToHubWithEventingUrl + // is still in flight — the exact race the fix addresses. + (signalRService.connectToHubWithEventingUrl as jest.Mock).mockImplementation(async () => { + handlers['onConnected']?.(); + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + expect(result.current.isUpdateHubConnected).toBe(true); }); it('should join the department group with the parsed DepartmentId', async () => { @@ -353,6 +406,47 @@ describe('useSignalRStore', () => { expect(result.current.lastUpdateTimestamps.weatherAlertReceived).toBe(result.current.lastUpdateTimestamp); }); + it('should refresh the calls list once for a burst of call events (debounced)', async () => { + jest.useFakeTimers(); + try { + const handlers: Record void> = {}; + (signalRService.on as jest.Mock).mockImplementation((event: string, handler: (message: unknown) => void) => { + handlers[event] = handler; + }); + + const { result } = renderHook(() => useSignalRStore()); + + await act(async () => { + await result.current.connectUpdateHub(); + }); + + mockFetchCalls.mockClear(); + + act(() => { + handlers['callAdded']({ CallId: '1' }); + handlers['callsUpdated']({ CallId: '2' }); + handlers['callClosed']({ CallId: '3' }); + }); + + // Still inside the debounce window — no refetch yet. + expect(mockFetchCalls).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(2000); + }); + + // A burst of three events coalesces into a single forced refresh. + expect(mockFetchCalls).toHaveBeenCalledTimes(1); + expect(mockFetchCalls).toHaveBeenCalledWith(true); + + // Timestamps still update immediately for the map hook. + expect(result.current.lastUpdateTimestamps.callAdded).toBeGreaterThan(0); + expect(result.current.lastUpdateTimestamps.callClosed).toBeGreaterThan(0); + } finally { + jest.useRealTimers(); + } + }); + it('should remove hub-scoped lifecycle listeners before connecting', async () => { const { result } = renderHook(() => useSignalRStore()); @@ -413,9 +507,7 @@ describe('useSignalRStore', () => { }); expect(signalRService.connectToHubWithEventingUrl).not.toHaveBeenCalled(); - expect(result.current.error).toEqual( - new Error('EventingUrl not available in config. Please ensure config is loaded first.') - ); + expect(result.current.error).toEqual(new Error('EventingUrl not available in config. Please ensure config is loaded first.')); }); it('should register no-op location handlers that do not write to the store', async () => { diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 994c4d17..1e66bd1d 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,7 @@ import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useIncidentCommandStore } from '../calls/incident-command-store'; +import { useCallsStore } from '../calls/store'; import { useChatStore } from '../chat/store'; import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore } from '../security/store'; @@ -195,6 +196,27 @@ function extractCommandCallId(message: unknown): string | undefined { return undefined; } +// A dispatch fan-out raises several call events in quick succession; debounce so a +// burst coalesces into a single calls-list refresh. The per-event timestamps still +// update immediately for consumers (e.g. the map hook) that key off them. +const CALLS_REFRESH_DEBOUNCE_MS = 2000; +let callsRefreshTimer: ReturnType | null = null; + +function scheduleCallsRefresh(): void { + if (callsRefreshTimer) { + clearTimeout(callsRefreshTimer); + } + callsRefreshTimer = setTimeout(() => { + callsRefreshTimer = null; + useCallsStore + .getState() + .fetchCalls(true) + .catch((error: unknown) => { + logger.warn({ message: 'Failed to refresh calls after SignalR call event', context: { error } }); + }); + }, CALLS_REFRESH_DEBOUNCE_MS); +} + /** Update-hub events that carry a per-event timestamp for targeted refetches. */ export const UPDATE_HUB_EVENTS = [ 'personnelStatusUpdated', @@ -315,15 +337,11 @@ export const useSignalRStore = create((set, get) => ({ const updateEvents = [...UPDATE_HUB_EVENTS, 'onConnected', updateHubDisconnected, updateHubReconnecting, updateHubReconnected]; updateEvents.forEach((event) => signalRService.removeAllListeners(event)); - // Connect to the eventing hub - await signalRService.connectToHubWithEventingUrl({ - name: Env.CHANNEL_HUB_NAME, - eventingUrl: eventingUrl, - hubName: Env.CHANNEL_HUB_NAME, - methods: [...UPDATE_HUB_EVENTS, 'onConnected'], - }); - - await joinDepartmentGroup(); + // Register every listener BEFORE starting the connection: the server raises + // onConnected as soon as the transport is up, so registering afterwards can + // drop the event and leave isUpdateHubConnected stuck at false. The + // removeAllListeners sweep above guards against duplicate registration when + // connectUpdateHub runs again. // Connection lifecycle: clear the connected flag when the hub drops so // connectUpdateHub() can recover, and re-join the department group + @@ -430,9 +448,21 @@ export const useSignalRStore = create((set, get) => ({ signalRService.on('personnelStatusUpdated', recordEvent('personnelStatusUpdated')); signalRService.on('personnelStaffingUpdated', recordEvent('personnelStaffingUpdated')); - signalRService.on('callsUpdated', recordEvent('callsUpdated')); - signalRService.on('callAdded', recordEvent('callAdded')); - signalRService.on('callClosed', recordEvent('callClosed')); + + // Call events also refresh the calls list itself (debounced) — the + // timestamps alone only drive the map hook, not the list data. + signalRService.on('callsUpdated', (message) => { + recordEvent('callsUpdated')(message); + scheduleCallsRefresh(); + }); + signalRService.on('callAdded', (message) => { + recordEvent('callAdded')(message); + scheduleCallsRefresh(); + }); + signalRService.on('callClosed', (message) => { + recordEvent('callClosed')(message); + scheduleCallsRefresh(); + }); // unitStatusUpdated additionally keeps its raw payload for the status hook signalRService.on('unitStatusUpdated', (message) => { @@ -492,12 +522,20 @@ export const useSignalRStore = create((set, get) => ({ }); set({ isUpdateHubConnected: true, error: null }); }); + + // Connect to the eventing hub + await signalRService.connectToHubWithEventingUrl({ + name: Env.CHANNEL_HUB_NAME, + eventingUrl: eventingUrl, + hubName: Env.CHANNEL_HUB_NAME, + methods: [...UPDATE_HUB_EVENTS, 'onConnected'], + }); + + await joinDepartmentGroup(); } catch (error) { const err = error instanceof Error ? error : new Error('Unknown error occurred'); - logger.warn({ - message: 'Failed to connect to SignalR hubs', - context: { error: err }, - }); + // The service already logged the connect failure with hub context; logging + // here again would double-report the same transient failure. set({ error: err }); } }, @@ -545,13 +583,9 @@ export const useSignalRStore = create((set, get) => ({ const geoEvents = ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect', geoHubDisconnected]; geoEvents.forEach((event) => signalRService.removeAllListeners(event)); - // Connect to the geolocation hub - await signalRService.connectToHubWithEventingUrl({ - name: Env.REALTIME_GEO_HUB_NAME, - eventingUrl: eventingUrl, - hubName: Env.REALTIME_GEO_HUB_NAME, - methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], - }); + // Register listeners BEFORE starting the connection so an early + // onGeolocationConnect from the server cannot be dropped, which would + // leave isGeolocationHubConnected stuck at false. // NOTE: no per-message store writes here. Geolocation messages fire per // unit per location cycle and nothing in the app consumes them — writing @@ -571,12 +605,18 @@ export const useSignalRStore = create((set, get) => ({ }); set({ isGeolocationHubConnected: true, error: null }); }); + + // Connect to the geolocation hub + await signalRService.connectToHubWithEventingUrl({ + name: Env.REALTIME_GEO_HUB_NAME, + eventingUrl: eventingUrl, + hubName: Env.REALTIME_GEO_HUB_NAME, + methods: ['onPersonnelLocationUpdated', 'onUnitLocationUpdated', 'onGeolocationConnect'], + }); } catch (error) { const err = error instanceof Error ? error : new Error('Unknown error occurred'); - logger.warn({ - message: 'Failed to connect to SignalR hubs', - context: { error: err }, - }); + // The service already logged the connect failure with hub context; logging + // here again would double-report the same transient failure. set({ error: err }); } }, diff --git a/src/translations/ar.json b/src/translations/ar.json index a04c3daa..2971db58 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "تعذر تحميل بيانات التطبيق. يرجى التحقق من اتصالك والمحاولة مرة أخرى.", "title": "وحدة ريسغريد" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "مطبق", "audio": "صوت", "audioActive": "الصوت نشط", + "audio_capable": "يدعم الصوت", "audio_device": "سماعة BT", "audio_output": "مخرج الصوت", "availableDevices": "الأجهزة المتاحة", @@ -47,6 +49,8 @@ "connect": "اتصال", "connected": "متصل", "connectionError": "خطأ في الاتصال", + "connection_error_message": "تعذر الاتصال بالجهاز", + "connection_error_title": "فشل الاتصال", "current_selection": "الاختيار الحالي", "device_disconnected": "تم فصل الجهاز", "disconnect": "قطع الاتصال", @@ -104,10 +108,12 @@ "loading": "جاري التحميل...", "no_images": "لا توجد صور متاحة", "no_images_description": "أضف صورًا إلى مكالمتك للمساعدة في التوثيق والتواصل", + "not_signed_in": "يجب تسجيل الدخول لرفع الصور", "select_from_gallery": "اختر من المعرض", "take_photo": "التقط صورة", "title": "صور المكالمة", - "upload": "رفع" + "upload": "رفع", + "upload_error": "خطأ في رفع الصورة" }, "callNotes": { "addNote": "إضافة ملاحظة", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "الاتجاهات", + "directions_to_destination": "الاتجاهات إلى الوجهة", "dispatch_to": "إرسال إلى", "dispatch_to_everyone": "إرسال إلى جميع الموظفين المتاحين", "edit_call": "تعديل المكالمة", "edit_call_description": "تحديث معلومات المكالمة", + "errors": { + "load_failed": "فشل تحميل المكالمات" + }, "everyone": "الجميع", "field_policy_loading": "لا يزال يتم تحميل إعدادات البلاغات في قسمك، يرجى المحاولة مرة أخرى بعد لحظات", "files": { + "error": "خطأ في الحصول على الملفات", + "file_name": "اسم الملف", "no_files": "لا توجد ملفات متاحة", "no_files_description": "لم تتم إضافة أي ملفات إلى هذه المكالمة بعد", + "open_error": "خطأ في فتح الملف", + "share_error": "خطأ في مشاركة الملف", + "sharing_unavailable": "المشاركة غير متاحة على هذا الجهاز", "title": "ملفات المكالمة" }, "geocoding_error": "فشل البحث عن العنوان، يرجى المحاولة مرة أخرى", @@ -308,6 +323,7 @@ "users": "المستخدمين", "viewNotes": "ملاحظات", "view_details": "عرض التفاصيل", + "view_on_map": "عرض على الخريطة", "what3words": "what3words", "what3words_found": "تم العثور على عنوان what3words وتم تحديث الموقع", "what3words_geocoding_error": "فشل في البحث عن عنوان what3words، يرجى المحاولة مرة أخرى", @@ -433,6 +449,7 @@ "add": "إضافة", "back": "رجوع", "cancel": "إلغاء", + "clear_search": "مسح البحث", "close": "إغلاق", "confirm": "تأكيد", "confirm_location": "تأكيد الموقع", @@ -454,6 +471,7 @@ "no_address_found": "لم يتم العثور على عنوان", "no_location": "لا توجد بيانات موقع متاحة", "no_results_found": "لم يتم العثور على نتائج", + "no_unit": "لا توجد وحدة", "no_unit_selected": "لم يتم اختيار وحدة", "nothingToDisplay": "لا يوجد شيء للعرض في الوقت الحالي", "of": "من", @@ -476,6 +494,7 @@ "tap_map_to_select": "انقر على الخريطة لتحديد الموقع", "tryAgainLater": "يرجى المحاولة مرة أخرى لاحقًا", "unknown": "غير معروف", + "unknown_error": "حدث خطأ غير معروف", "upload": "رفع", "uploading": "جاري الرفع..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "جهات الاتصال", "twitter": "تويتر", + "unknown_company": "شركة غير معروفة", + "unknown_person": "شخص غير معروف", "visibility": "الرؤية", "website": "الموقع الإلكتروني", "zip": "الرمز البريدي" @@ -659,6 +680,7 @@ "message": "يرجى التحقق من اسم المستخدم وكلمة المرور والمحاولة مرة أخرى.", "title": "فشل تسجيل الدخول" }, + "hide_password": "إخفاء كلمة المرور", "login": "تسجيل الدخول", "login_button": "تسجيل الدخول", "login_button_description": "قم بتسجيل الدخول إلى حسابك للمتابعة", @@ -667,8 +689,8 @@ "login_button_success": "تم تسجيل الدخول بنجاح", "or_sign_in_with_password": "أو تسجيل الدخول بكلمة المرور", "password": "كلمة المرور", - "password_incorrect": "كانت كلمة المرور غير صحيحة", "password_placeholder": "أدخل كلمة المرور الخاصة بك", + "show_password": "إظهار كلمة المرور", "sso_button": "تسجيل الدخول عبر SSO", "sso_department": "SSO مقدم من {{name}}", "sso_department_id": "معرف القسم (اختياري)", @@ -689,6 +711,7 @@ "call_set_as_current": "تم تعيين المكالمة كمكالمة حالية", "failed_to_open_maps": "فشل في فتح تطبيق الخرائط", "failed_to_set_current_call": "فشل في تعيين المكالمة كمكالمة حالية", + "lock_map": "تثبيت الخريطة على الوحدة", "no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه", "pin_address": "Address", "pin_color": "لون الدبوس", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "إعادة توسيط الخريطة", "set_as_current_call": "تعيين كمكالمة حالية", + "unlock_map": "إلغاء تثبيت الخريطة", "view_call_details": "عرض تفاصيل المكالمة", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "البحث في الملاحظات...", "title": "الملاحظات" }, + "notifications": { + "confirm_delete_message_count": "هل أنت متأكد من حذف {{count}} إشعارات؟ لا يمكن التراجع عن هذا الإجراء.", + "confirm_delete_message_one": "هل أنت متأكد من حذف هذا الإشعار؟ لا يمكن التراجع عن هذا الإجراء.", + "confirm_delete_title": "تأكيد الحذف", + "delete_selected": "حذف الإشعارات المحددة", + "deselect_all": "إلغاء تحديد الكل", + "empty": "لا توجد تحديثات", + "enter_selection_mode": "تحديد الإشعارات", + "open": "فتح الإشعارات", + "remove_failed_count": "فشل حذف الإشعارات", + "remove_failed_one": "فشل حذف الإشعار", + "removed_count": "تم حذف {{count}} إشعارات", + "removed_one": "تم حذف الإشعار", + "select_all": "تحديد الكل", + "selected_count": "تم تحديد {{count}}", + "title": "الإشعارات", + "unable_to_load": "تعذر تحميل الإشعارات" + }, "onboarding": { "message": "مرحبًا بك في تطبيق Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "عرض المكالمة" }, "roles": { + "assignedElsewhere": "في دور آخر", + "clearAssignment": "مسح الإسناد الحالي", "modal": { "title": "تعيينات أدوار الوحدة" }, + "noUsersFound": "لم يتم العثور على مستخدمين", + "save_error": "خطأ في حفظ إسناد الأدوار", + "saved_successfully": "تم حفظ إسناد الأدوار بنجاح", + "searchUsers": "ابحث بالاسم أو المجموعة...", "selectUser": "اختر المستخدم", + "selectUserDescription": "اختر شخصًا لهذا الدور", + "selectUserForRole": "إسناد: {{role}}", + "selectUserLabel": "تحديد {{name}}", "status": "{{active}} من {{total}} أدوار نشطة", + "tapToAssign": "اضغط لإسناد مستخدم إلى {{role}}", "tap_to_manage": "انقر لإدارة الأدوار", + "title": "إسناد أدوار الوحدة", "unassigned": "غير معين" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "المسافة الإجمالية", "total_duration": "المدة الإجمالية", "traffic_delay": "{{time}} تأخير بسبب حركة المرور", + "traffic_heavy": "ازدحام كثيف", + "traffic_light": "ازدحام خفيف", + "traffic_moderate": "ازدحام معتدل", + "traffic_no_data": "لا توجد بيانات مرور", + "traffic_severe": "ازدحام شديد", "try_different_search": "Try a different search term", "type": "النوع", "unassigned": "غير معين", diff --git a/src/translations/de.json b/src/translations/de.json index 1443f553..a6e7b2f7 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "App-Daten konnten nicht geladen werden. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Angewendet", "audio": "Audio", "audioActive": "Audio aktiv", + "audio_capable": "Audiofähig", "audio_device": "BT-Handset", "audio_output": "Audioausgabe", "availableDevices": "Verfügbare Geräte", @@ -47,6 +49,8 @@ "connect": "Verbinden", "connected": "Verbunden", "connectionError": "Verbindungsfehler", + "connection_error_message": "Verbindung zum Gerät nicht möglich", + "connection_error_title": "Verbindung fehlgeschlagen", "current_selection": "Aktuelle Auswahl", "device_disconnected": "Gerät getrennt", "disconnect": "Trennen", @@ -104,10 +108,12 @@ "loading": "Laden...", "no_images": "Keine Bilder verfügbar", "no_images_description": "Fügen Sie Ihrem Anruf Bilder hinzu, um die Dokumentation und Kommunikation zu erleichtern", + "not_signed_in": "Sie müssen angemeldet sein, um Bilder hochzuladen", "select_from_gallery": "Aus Galerie auswählen", "take_photo": "Foto aufnehmen", "title": "Anrufbilder", - "upload": "Hochladen" + "upload": "Hochladen", + "upload_error": "Fehler beim Hochladen des Bildes" }, "callNotes": { "addNote": "Notiz hinzufügen", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Wegbeschreibung", + "directions_to_destination": "Route zum Ziel", "dispatch_to": "Disponieren an", "dispatch_to_everyone": "An alle verfügbaren Einsatzkräfte disponieren", "edit_call": "Anruf bearbeiten", "edit_call_description": "Anrufinformationen aktualisieren", + "errors": { + "load_failed": "Anrufe konnten nicht geladen werden" + }, "everyone": "Alle", "field_policy_loading": "Die Einsatzeinstellungen Ihrer Abteilung werden noch geladen, bitte versuchen Sie es gleich erneut", "files": { + "error": "Fehler beim Abrufen der Dateien", + "file_name": "Dateiname", "no_files": "Keine Dateien verfügbar", "no_files_description": "Diesem Anruf wurden noch keine Dateien hinzugefügt", + "open_error": "Fehler beim Öffnen der Datei", + "share_error": "Fehler beim Teilen der Datei", + "sharing_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar", "title": "Anrufdateien" }, "geocoding_error": "Adresssuche fehlgeschlagen, bitte erneut versuchen", @@ -308,6 +323,7 @@ "users": "Benutzer", "viewNotes": "Notizen", "view_details": "Details anzeigen", + "view_on_map": "Auf Karte anzeigen", "what3words": "what3words", "what3words_found": "what3words-Adresse gefunden und Standort aktualisiert", "what3words_geocoding_error": "Suche nach what3words-Adresse fehlgeschlagen, bitte erneut versuchen", @@ -433,6 +449,7 @@ "add": "Hinzufügen", "back": "Zurück", "cancel": "Abbrechen", + "clear_search": "Suche löschen", "close": "Schließen", "confirm": "Bestätigen", "confirm_location": "Standort bestätigen", @@ -454,6 +471,7 @@ "no_address_found": "Keine Adresse gefunden", "no_location": "Keine Standortdaten verfügbar", "no_results_found": "Keine Ergebnisse gefunden", + "no_unit": "Keine Einheit", "no_unit_selected": "Keine Einheit ausgewählt", "nothingToDisplay": "Im Moment gibt es nichts anzuzeigen", "of": "von", @@ -476,6 +494,7 @@ "tap_map_to_select": "Auf die Karte tippen, um einen Standort auszuwählen", "tryAgainLater": "Bitte später erneut versuchen", "unknown": "Unbekannt", + "unknown_error": "Ein unbekannter Fehler ist aufgetreten", "upload": "Hochladen", "uploading": "Hochladen..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Kontakte", "twitter": "Twitter", + "unknown_company": "Unbekanntes Unternehmen", + "unknown_person": "Unbekannte Person", "visibility": "Sichtbarkeit", "website": "Website", "zip": "Postleitzahl" @@ -659,6 +680,7 @@ "message": "Bitte Benutzername und Passwort überprüfen und erneut versuchen.", "title": "Anmeldung fehlgeschlagen" }, + "hide_password": "Passwort verbergen", "login": "Anmelden", "login_button": "Anmelden", "login_button_description": "Bei Ihrem Konto anmelden, um fortzufahren", @@ -667,8 +689,8 @@ "login_button_success": "Erfolgreich angemeldet", "or_sign_in_with_password": "oder mit Passwort anmelden", "password": "Passwort", - "password_incorrect": "Passwort war falsch", "password_placeholder": "Passwort eingeben", + "show_password": "Passwort anzeigen", "sso_button": "Mit SSO anmelden", "sso_department": "SSO bereitgestellt von {{name}}", "sso_department_id": "Abteilungs-ID (optional)", @@ -689,6 +711,7 @@ "call_set_as_current": "Anruf als aktuellen Anruf gesetzt", "failed_to_open_maps": "Kartenanwendung konnte nicht geöffnet werden", "failed_to_set_current_call": "Anruf konnte nicht als aktueller Anruf gesetzt werden", + "lock_map": "Karte an Einheit binden", "no_location_for_routing": "Keine Standortdaten für die Navigation verfügbar", "pin_address": "Address", "pin_color": "Pin-Farbe", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Karte neu zentrieren", "set_as_current_call": "Als aktuellen Anruf setzen", + "unlock_map": "Karte entsperren", "view_call_details": "Anrufdetails anzeigen", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Notizen suchen...", "title": "Notizen" }, + "notifications": { + "confirm_delete_message_count": "Möchten Sie {{count}} Benachrichtigungen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_delete_message_one": "Möchten Sie diese Benachrichtigung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_delete_title": "Löschen bestätigen", + "delete_selected": "Ausgewählte Benachrichtigungen löschen", + "deselect_all": "Auswahl aufheben", + "empty": "Keine Neuigkeiten vorhanden", + "enter_selection_mode": "Benachrichtigungen auswählen", + "open": "Benachrichtigungen öffnen", + "remove_failed_count": "Benachrichtigungen konnten nicht gelöscht werden", + "remove_failed_one": "Benachrichtigung konnte nicht gelöscht werden", + "removed_count": "{{count}} Benachrichtigungen gelöscht", + "removed_one": "Benachrichtigung gelöscht", + "select_all": "Alle auswählen", + "selected_count": "{{count}} ausgewählt", + "title": "Benachrichtigungen", + "unable_to_load": "Benachrichtigungen konnten nicht geladen werden" + }, "onboarding": { "message": "Willkommen bei Resgrid Unit app site" }, @@ -782,12 +824,23 @@ "view_call": "Anruf anzeigen" }, "roles": { + "assignedElsewhere": "In einer anderen Rolle", + "clearAssignment": "Aktuelle Zuweisung entfernen", "modal": { "title": "Einheitsrollenzuweisungen" }, + "noUsersFound": "Keine Benutzer gefunden", + "save_error": "Fehler beim Speichern der Rollenzuweisungen", + "saved_successfully": "Rollenzuweisungen gespeichert", + "searchUsers": "Nach Name oder Gruppe suchen...", "selectUser": "Benutzer auswählen", + "selectUserDescription": "Wählen Sie eine Person für diese Rolle", + "selectUserForRole": "Zuweisen: {{role}}", + "selectUserLabel": "{{name}} auswählen", "status": "{{active}} von {{total}} Rollen", + "tapToAssign": "Tippen, um {{role}} einen Benutzer zuzuweisen", "tap_to_manage": "Tippen, um Rollen zu verwalten", + "title": "Rollenzuweisung der Einheit", "unassigned": "Nicht zugewiesen" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Gesamtentfernung", "total_duration": "Gesamtdauer", "traffic_delay": "{{time}} Verzögerung durch Verkehr", + "traffic_heavy": "Dichter Verkehr", + "traffic_light": "Wenig Verkehr", + "traffic_moderate": "Mäßiger Verkehr", + "traffic_no_data": "Keine Verkehrsdaten", + "traffic_severe": "Sehr dichter Verkehr", "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Nicht zugewiesen", diff --git a/src/translations/el.json b/src/translations/el.json index ffa9ff43..5fd8959a 100644 --- a/src/translations/el.json +++ b/src/translations/el.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Δεν ήταν δυνατή η φόρτωση των δεδομένων της εφαρμογής. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Εφαρμόστηκε", "audio": "Ήχος", "audioActive": "Ήχος Ενεργός", + "audio_capable": "Υποστηρίζει ήχο", "audio_device": "Ακουστικό BT", "audio_output": "Έξοδος Ήχου", "availableDevices": "Διαθέσιμες Συσκευές", @@ -47,6 +49,8 @@ "connect": "Σύνδεση", "connected": "Συνδεδεμένη", "connectionError": "Σφάλμα Σύνδεσης", + "connection_error_message": "Δεν ήταν δυνατή η σύνδεση με τη συσκευή", + "connection_error_title": "Η σύνδεση απέτυχε", "current_selection": "Τρέχουσα Επιλογή", "device_disconnected": "Η συσκευή αποσυνδέθηκε", "disconnect": "Αποσύνδεση", @@ -104,10 +108,12 @@ "loading": "Φόρτωση...", "no_images": "Δεν υπάρχουν διαθέσιμες εικόνες", "no_images_description": "Προσθέστε εικόνες στην κλήση σας για να βοηθήσετε στην τεκμηρίωση και την επικοινωνία", + "not_signed_in": "Πρέπει να συνδεθείτε για να μεταφορτώσετε εικόνες", "select_from_gallery": "Επιλογή από τη Συλλογή", "take_photo": "Λήψη Φωτογραφίας", "title": "Εικόνες Κλήσης", - "upload": "Μεταφόρτωση" + "upload": "Μεταφόρτωση", + "upload_error": "Σφάλμα κατά τη μεταφόρτωση της εικόνας" }, "callNotes": { "addNote": "Προσθήκη Σημείωσης", @@ -241,15 +247,24 @@ "destination_poi": "Σημείο Ενδιαφέροντος Προορισμού", "destination_poi_none": "Δεν επιλέχθηκε προορισμός", "directions": "Οδηγίες", + "directions_to_destination": "Οδηγίες προς τον προορισμό", "dispatch_to": "Αποστολή Προς", "dispatch_to_everyone": "Αποστολή σε όλο το διαθέσιμο προσωπικό", "edit_call": "Επεξεργασία Κλήσης", "edit_call_description": "Ενημέρωση πληροφοριών κλήσης", + "errors": { + "load_failed": "Η φόρτωση των κλήσεων απέτυχε" + }, "everyone": "Όλοι", "field_policy_loading": "Οι ρυθμίσεις κλήσεων του τμήματός σας φορτώνονται ακόμη, δοκιμάστε ξανά σε λίγο", "files": { + "error": "Σφάλμα κατά τη λήψη των αρχείων", + "file_name": "Όνομα αρχείου", "no_files": "Δεν υπάρχουν διαθέσιμα αρχεία", "no_files_description": "Δεν έχουν προστεθεί ακόμη αρχεία σε αυτή την κλήση", + "open_error": "Σφάλμα κατά το άνοιγμα του αρχείου", + "share_error": "Σφάλμα κατά την κοινοποίηση του αρχείου", + "sharing_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτήν τη συσκευή", "title": "Αρχεία Κλήσης" }, "geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης, δοκιμάστε ξανά", @@ -308,6 +323,7 @@ "users": "Χρήστες", "viewNotes": "Σημειώσεις", "view_details": "Προβολή Λεπτομερειών", + "view_on_map": "Προβολή στον χάρτη", "what3words": "what3words", "what3words_found": "Η διεύθυνση what3words βρέθηκε και η τοποθεσία ενημερώθηκε", "what3words_geocoding_error": "Αποτυχία αναζήτησης διεύθυνσης what3words, δοκιμάστε ξανά", @@ -433,6 +449,7 @@ "add": "Προσθήκη", "back": "Πίσω", "cancel": "Ακύρωση", + "clear_search": "Καθαρισμός αναζήτησης", "close": "Κλείσιμο", "confirm": "Επιβεβαίωση", "confirm_location": "Επιβεβαίωση Τοποθεσίας", @@ -454,6 +471,7 @@ "no_address_found": "Δεν βρέθηκε διεύθυνση", "no_location": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας", "no_results_found": "Δεν βρέθηκαν αποτελέσματα", + "no_unit": "Καμία μονάδα", "no_unit_selected": "Δεν Επιλέχθηκε Μονάδα", "nothingToDisplay": "Δεν υπάρχει τίποτα να εμφανιστεί αυτή τη στιγμή", "of": "από", @@ -476,6 +494,7 @@ "tap_map_to_select": "Πατήστε στον χάρτη για να επιλέξετε τοποθεσία", "tryAgainLater": "Δοκιμάστε ξανά αργότερα", "unknown": "Άγνωστο", + "unknown_error": "Παρουσιάστηκε άγνωστο σφάλμα", "upload": "Μεταφόρτωση", "uploading": "Μεταφόρτωση..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Επαφές", "twitter": "Twitter", + "unknown_company": "Άγνωστη εταιρεία", + "unknown_person": "Άγνωστο άτομο", "visibility": "Ορατότητα", "website": "Ιστότοπος", "zip": "Ταχυδρομικός Κώδικας" @@ -659,6 +680,7 @@ "message": "Ελέγξτε το όνομα χρήστη και τον κωδικό πρόσβασής σας και δοκιμάστε ξανά.", "title": "Η Σύνδεση Απέτυχε" }, + "hide_password": "Απόκρυψη κωδικού", "login": "Σύνδεση", "login_button": "Σύνδεση", "login_button_description": "Συνδεθείτε στον λογαριασμό σας για να συνεχίσετε", @@ -667,8 +689,8 @@ "login_button_success": "Επιτυχής σύνδεση", "or_sign_in_with_password": "ή συνδεθείτε με κωδικό πρόσβασης", "password": "Κωδικός Πρόσβασης", - "password_incorrect": "Ο κωδικός πρόσβασης ήταν λανθασμένος", "password_placeholder": "Εισαγάγετε τον κωδικό πρόσβασής σας", + "show_password": "Εμφάνιση κωδικού", "sso_button": "Συνδεθείτε με SSO", "sso_department": "Το SSO παρέχεται από {{name}}", "sso_department_id": "Αναγνωριστικό Τμήματος (προαιρετικό)", @@ -689,6 +711,7 @@ "call_set_as_current": "Η κλήση ορίστηκε ως τρέχουσα κλήση", "failed_to_open_maps": "Αποτυχία ανοίγματος εφαρμογής χαρτών", "failed_to_set_current_call": "Αποτυχία ορισμού της κλήσης ως τρέχουσας", + "lock_map": "Κλείδωμα χάρτη στη μονάδα", "no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση", "pin_address": "Διεύθυνση", "pin_color": "Χρώμα Πινέζας", @@ -696,6 +719,7 @@ "pin_type": "Τύπος Σημείου Ενδιαφέροντος", "recenter_map": "Επανακεντράρισμα Χάρτη", "set_as_current_call": "Ορισμός ως Τρέχουσα Κλήση", + "unlock_map": "Ξεκλείδωμα χάρτη", "view_call_details": "Προβολή Λεπτομερειών Κλήσης", "view_poi_details": "Προβολή Λεπτομερειών Σημείου Ενδιαφέροντος" }, @@ -750,6 +774,24 @@ "search": "Αναζήτηση σημειώσεων...", "title": "Σημειώσεις" }, + "notifications": { + "confirm_delete_message_count": "Είστε βέβαιοι ότι θέλετε να διαγράψετε {{count}} ειδοποιήσεις; Η ενέργεια δεν είναι αναστρέψιμη.", + "confirm_delete_message_one": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν την ειδοποίηση; Η ενέργεια δεν είναι αναστρέψιμη.", + "confirm_delete_title": "Επιβεβαίωση διαγραφής", + "delete_selected": "Διαγραφή επιλεγμένων ειδοποιήσεων", + "deselect_all": "Αποεπιλογή όλων", + "empty": "Δεν υπάρχουν ενημερώσεις", + "enter_selection_mode": "Επιλογή ειδοποιήσεων", + "open": "Άνοιγμα ειδοποιήσεων", + "remove_failed_count": "Η διαγραφή των ειδοποιήσεων απέτυχε", + "remove_failed_one": "Η διαγραφή της ειδοποίησης απέτυχε", + "removed_count": "Διαγράφηκαν {{count}} ειδοποιήσεις", + "removed_one": "Η ειδοποίηση διαγράφηκε", + "select_all": "Επιλογή όλων", + "selected_count": "{{count}} επιλεγμένες", + "title": "Ειδοποιήσεις", + "unable_to_load": "Δεν ήταν δυνατή η φόρτωση των ειδοποιήσεων" + }, "onboarding": { "message": "Καλώς ήρθατε στον ιστότοπο της εφαρμογής Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Προβολή Κλήσης" }, "roles": { + "assignedElsewhere": "Σε άλλον ρόλο", + "clearAssignment": "Καθαρισμός της τρέχουσας ανάθεσης", "modal": { "title": "Αναθέσεις Ρόλων Μονάδας" }, + "noUsersFound": "Δεν βρέθηκαν χρήστες", + "save_error": "Σφάλμα κατά την αποθήκευση των αναθέσεων ρόλων", + "saved_successfully": "Οι αναθέσεις ρόλων αποθηκεύτηκαν με επιτυχία", + "searchUsers": "Αναζήτηση με όνομα ή ομάδα...", "selectUser": "Επιλογή χρήστη", + "selectUserDescription": "Επιλέξτε ένα άτομο για αυτόν τον ρόλο", + "selectUserForRole": "Ανάθεση: {{role}}", + "selectUserLabel": "Επιλογή {{name}}", "status": "{{active}} από {{total}} Ρόλους", + "tapToAssign": "Πατήστε για ανάθεση χρήστη στον ρόλο {{role}}", "tap_to_manage": "Πατήστε για διαχείριση ρόλων", + "title": "Αναθέσεις ρόλων μονάδας", "unassigned": "Χωρίς ανάθεση" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Συνολική Απόσταση", "total_duration": "Συνολική Διάρκεια", "traffic_delay": "Καθυστέρηση {{time}} λόγω κυκλοφορίας", + "traffic_heavy": "Πυκνή κυκλοφορία", + "traffic_light": "Ελαφριά κυκλοφορία", + "traffic_moderate": "Μέτρια κυκλοφορία", + "traffic_no_data": "Δεν υπάρχουν δεδομένα κυκλοφορίας", + "traffic_severe": "Πολύ πυκνή κυκλοφορία", "try_different_search": "Δοκιμάστε διαφορετικό όρο αναζήτησης", "type": "Τύπος", "unassigned": "Χωρίς ανάθεση", diff --git a/src/translations/en.json b/src/translations/en.json index 535b7393..bc0f2746 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Unable to load app data. Please check your connection and try again.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Applied", "audio": "Audio", "audioActive": "Audio Active", + "audio_capable": "Audio capable", "audio_device": "BT Handset", "audio_output": "Audio Output", "availableDevices": "Available Devices", @@ -47,6 +49,8 @@ "connect": "Connect", "connected": "Connected", "connectionError": "Connection Error", + "connection_error_message": "Could not connect to device", + "connection_error_title": "Connection Failed", "current_selection": "Current Selection", "device_disconnected": "Device disconnected", "disconnect": "Disconnect", @@ -104,10 +108,12 @@ "loading": "Loading...", "no_images": "No images available", "no_images_description": "Add images to your call to help with documentation and communication", + "not_signed_in": "You must be signed in to upload images", "select_from_gallery": "Select from Gallery", "take_photo": "Take Photo", "title": "Call Images", - "upload": "Upload" + "upload": "Upload", + "upload_error": "Error uploading image" }, "callNotes": { "addNote": "Add Note", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Directions", + "directions_to_destination": "Directions to destination", "dispatch_to": "Dispatch To", "dispatch_to_everyone": "Dispatch to all available personnel", "edit_call": "Edit Call", "edit_call_description": "Update call information", + "errors": { + "load_failed": "Failed to load calls" + }, "everyone": "Everyone", "field_policy_loading": "Still loading your department's call settings, please try again in a moment", "files": { + "error": "Error getting files", + "file_name": "File Name", "no_files": "No files available", "no_files_description": "No files have been added to this call yet", + "open_error": "Error opening file", + "share_error": "Error sharing file", + "sharing_unavailable": "Sharing is not available on this device", "title": "Call Files" }, "geocoding_error": "Failed to search for address, please try again", @@ -308,6 +323,7 @@ "users": "Users", "viewNotes": "Notes", "view_details": "View Details", + "view_on_map": "View on map", "what3words": "what3words", "what3words_found": "what3words address found and location updated", "what3words_geocoding_error": "Failed to search for what3words address, please try again", @@ -433,6 +449,7 @@ "add": "Add", "back": "Back", "cancel": "Cancel", + "clear_search": "Clear search", "close": "Close", "confirm": "Confirm", "confirm_location": "Confirm Location", @@ -454,6 +471,7 @@ "no_address_found": "No address found", "no_location": "No location data available", "no_results_found": "No results found", + "no_unit": "No Unit", "no_unit_selected": "No Unit Selected", "nothingToDisplay": "There's nothing to display at the moment", "of": "of", @@ -476,6 +494,7 @@ "tap_map_to_select": "Tap on the map to select a location", "tryAgainLater": "Please try again later", "unknown": "Unknown", + "unknown_error": "Unknown error occurred", "upload": "Upload", "uploading": "Uploading..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Contacts", "twitter": "Twitter", + "unknown_company": "Unknown Company", + "unknown_person": "Unknown Person", "visibility": "Visibility", "website": "Website", "zip": "Zip Code" @@ -659,6 +680,7 @@ "message": "Please check your username and password and try again.", "title": "Login Failed" }, + "hide_password": "Hide password", "login": "Login", "login_button": "Sign In", "login_button_description": "Login to your account to continue", @@ -667,8 +689,8 @@ "login_button_success": "Logged in successfully", "or_sign_in_with_password": "or sign in with password", "password": "Password", - "password_incorrect": "Password was incorrect", "password_placeholder": "Enter your password", + "show_password": "Show password", "sso_button": "Sign in with SSO", "sso_department": "SSO provided by {{name}}", "sso_department_id": "Department ID (optional)", @@ -689,6 +711,7 @@ "call_set_as_current": "Call set as current call", "failed_to_open_maps": "Failed to open maps application", "failed_to_set_current_call": "Failed to set call as current call", + "lock_map": "Lock map to unit", "no_location_for_routing": "No location data available for routing", "pin_address": "Address", "pin_color": "Pin Color", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Recenter Map", "set_as_current_call": "Set as Current Call", + "unlock_map": "Unlock map", "view_call_details": "View Call Details", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Search notes...", "title": "Notes" }, + "notifications": { + "confirm_delete_message_count": "Are you sure you want to delete {{count}} notifications? This action cannot be undone.", + "confirm_delete_message_one": "Are you sure you want to delete this notification? This action cannot be undone.", + "confirm_delete_title": "Confirm Delete", + "delete_selected": "Delete selected notifications", + "deselect_all": "Deselect All", + "empty": "No updates available", + "enter_selection_mode": "Select notifications", + "open": "Open notifications", + "remove_failed_count": "Failed to remove notifications", + "remove_failed_one": "Failed to remove notification", + "removed_count": "{{count}} notifications removed", + "removed_one": "Notification removed", + "select_all": "Select All", + "selected_count": "{{count}} selected", + "title": "Notifications", + "unable_to_load": "Unable to load notifications" + }, "onboarding": { "message": "Welcome to Resgrid Unit app site" }, @@ -782,12 +824,23 @@ "view_call": "View Call" }, "roles": { + "assignedElsewhere": "In another role", + "clearAssignment": "Clear the current assignment", "modal": { "title": "Unit Role Assignments" }, + "noUsersFound": "No users found", + "save_error": "Error saving role assignments", + "saved_successfully": "Role assignments saved successfully", + "searchUsers": "Search by name or group...", "selectUser": "Select user", + "selectUserDescription": "Choose a person to fill this role", + "selectUserForRole": "Assign: {{role}}", + "selectUserLabel": "Select {{name}}", "status": "{{active}} of {{total}} Roles", + "tapToAssign": "Tap to assign user to {{role}}", "tap_to_manage": "Tap to manage roles", + "title": "Unit Role Assignments", "unassigned": "Unassigned" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Total Distance", "total_duration": "Total Duration", "traffic_delay": "{{time}} delay due to traffic", + "traffic_heavy": "Heavy traffic", + "traffic_light": "Light traffic", + "traffic_moderate": "Moderate traffic", + "traffic_no_data": "No traffic data", + "traffic_severe": "Severe traffic", "try_different_search": "Try a different search term", "type": "Type", "unassigned": "Unassigned", diff --git a/src/translations/es.json b/src/translations/es.json index 536733f5..909ff5c2 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "No se pudieron cargar los datos de la aplicación. Comprueba tu conexión e inténtalo de nuevo.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Aplicado", "audio": "Audio", "audioActive": "Audio Activo", + "audio_capable": "Compatible con audio", "audio_device": "Auricular BT", "audio_output": "Salida de audio", "availableDevices": "Dispositivos Disponibles", @@ -47,6 +49,8 @@ "connect": "Conectar", "connected": "Conectado", "connectionError": "Error de Conexión", + "connection_error_message": "No se pudo conectar con el dispositivo", + "connection_error_title": "Error de conexión", "current_selection": "Selección Actual", "device_disconnected": "Dispositivo desconectado", "disconnect": "Desconectar", @@ -104,10 +108,12 @@ "loading": "Cargando...", "no_images": "No hay imágenes disponibles", "no_images_description": "Añade imágenes a tu llamada para ayudar con la documentación y comunicación", + "not_signed_in": "Debes iniciar sesión para subir imágenes", "select_from_gallery": "Seleccionar de la galería", "take_photo": "Tomar foto", "title": "Imágenes de la llamada", - "upload": "Subir" + "upload": "Subir", + "upload_error": "Error al subir la imagen" }, "callNotes": { "addNote": "Añadir nota", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Direcciones", + "directions_to_destination": "Indicaciones al destino", "dispatch_to": "Despachar A", "dispatch_to_everyone": "Despachar a todo el personal disponible", "edit_call": "Editar llamada", "edit_call_description": "Actualizar información de la llamada", + "errors": { + "load_failed": "Error al cargar las llamadas" + }, "everyone": "Todos", "field_policy_loading": "Aún se están cargando los ajustes de avisos de tu departamento, inténtalo de nuevo en un momento", "files": { + "error": "Error al obtener los archivos", + "file_name": "Nombre del archivo", "no_files": "No hay archivos disponibles", "no_files_description": "No se han agregado archivos a esta llamada aún", + "open_error": "Error al abrir el archivo", + "share_error": "Error al compartir el archivo", + "sharing_unavailable": "Compartir no está disponible en este dispositivo", "title": "Archivos de llamada" }, "geocoding_error": "Error al buscar la dirección, por favor inténtelo de nuevo", @@ -308,6 +323,7 @@ "users": "Usuarios", "viewNotes": "Notas", "view_details": "Ver detalles", + "view_on_map": "Ver en el mapa", "what3words": "what3words", "what3words_found": "Dirección what3words encontrada y ubicación actualizada", "what3words_geocoding_error": "Error al buscar dirección what3words, intente nuevamente", @@ -433,6 +449,7 @@ "add": "Añadir", "back": "Atrás", "cancel": "Cancelar", + "clear_search": "Borrar búsqueda", "close": "Cerrar", "confirm": "Confirmar", "confirm_location": "Confirmar ubicación", @@ -454,6 +471,7 @@ "no_address_found": "No se encontró dirección", "no_location": "No hay datos de ubicación disponibles", "no_results_found": "No se encontraron resultados", + "no_unit": "Sin unidad", "no_unit_selected": "Ninguna unidad seleccionada", "nothingToDisplay": "No hay nada que mostrar en este momento", "of": "de", @@ -476,6 +494,7 @@ "tap_map_to_select": "Toca el mapa para seleccionar una ubicación", "tryAgainLater": "Por favor, inténtelo de nuevo más tarde", "unknown": "Desconocido", + "unknown_error": "Se produjo un error desconocido", "upload": "Subir", "uploading": "Subiendo..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Contactos", "twitter": "Twitter", + "unknown_company": "Empresa desconocida", + "unknown_person": "Persona desconocida", "visibility": "Visibilidad", "website": "Sitio web", "zip": "Código postal" @@ -659,6 +680,7 @@ "message": "Por favor, comprueba tu nombre de usuario y contraseña e inténtalo de nuevo.", "title": "Inicio de sesión fallido" }, + "hide_password": "Ocultar contraseña", "login": "Iniciar sesión", "login_button": "Iniciar sesión", "login_button_description": "Inicia sesión en tu cuenta para continuar", @@ -667,8 +689,8 @@ "login_button_success": "Sesión iniciada con éxito", "or_sign_in_with_password": "o inicia sesión con contraseña", "password": "Contraseña", - "password_incorrect": "La contraseña era incorrecta", "password_placeholder": "Introduce tu contraseña", + "show_password": "Mostrar contraseña", "sso_button": "Iniciar sesión con SSO", "sso_department": "SSO proporcionado por {{name}}", "sso_department_id": "ID de departamento (opcional)", @@ -689,6 +711,7 @@ "call_set_as_current": "Llamada establecida como llamada actual", "failed_to_open_maps": "Error al abrir la aplicación de mapas", "failed_to_set_current_call": "Error al establecer la llamada como llamada actual", + "lock_map": "Bloquear el mapa en la unidad", "no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento", "pin_address": "Address", "pin_color": "Color del pin", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Recentrar mapa", "set_as_current_call": "Establecer como llamada actual", + "unlock_map": "Desbloquear el mapa", "view_call_details": "Ver detalles de la llamada", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Buscar notas...", "title": "Notas" }, + "notifications": { + "confirm_delete_message_count": "¿Seguro que quieres eliminar {{count}} notificaciones? Esta acción no se puede deshacer.", + "confirm_delete_message_one": "¿Seguro que quieres eliminar esta notificación? Esta acción no se puede deshacer.", + "confirm_delete_title": "Confirmar eliminación", + "delete_selected": "Eliminar las notificaciones seleccionadas", + "deselect_all": "Deseleccionar todo", + "empty": "No hay novedades", + "enter_selection_mode": "Seleccionar notificaciones", + "open": "Abrir notificaciones", + "remove_failed_count": "No se pudieron eliminar las notificaciones", + "remove_failed_one": "No se pudo eliminar la notificación", + "removed_count": "{{count}} notificaciones eliminadas", + "removed_one": "Notificación eliminada", + "select_all": "Seleccionar todo", + "selected_count": "{{count}} seleccionadas", + "title": "Notificaciones", + "unable_to_load": "No se pudieron cargar las notificaciones" + }, "onboarding": { "message": "Bienvenido al sitio de la aplicación Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Ver Llamada" }, "roles": { + "assignedElsewhere": "En otra función", + "clearAssignment": "Borrar la asignación actual", "modal": { "title": "Asignaciones de roles de unidad" }, + "noUsersFound": "No se encontraron usuarios", + "save_error": "Error al guardar las asignaciones de funciones", + "saved_successfully": "Asignaciones de funciones guardadas correctamente", + "searchUsers": "Buscar por nombre o grupo...", "selectUser": "Seleccionar usuario", + "selectUserDescription": "Elige a una persona para esta función", + "selectUserForRole": "Asignar: {{role}}", + "selectUserLabel": "Seleccionar a {{name}}", "status": "{{active}} de {{total}} roles activos", + "tapToAssign": "Toca para asignar un usuario a {{role}}", "tap_to_manage": "Toca para gestionar roles", + "title": "Asignación de funciones de la unidad", "unassigned": "Sin asignar" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Distancia Total", "total_duration": "Duración Total", "traffic_delay": "{{time}} de retraso por el tráfico", + "traffic_heavy": "Tráfico denso", + "traffic_light": "Tráfico fluido", + "traffic_moderate": "Tráfico moderado", + "traffic_no_data": "Sin datos de tráfico", + "traffic_severe": "Tráfico muy denso", "try_different_search": "Try a different search term", "type": "Tipo", "unassigned": "Sin asignar", diff --git a/src/translations/fr.json b/src/translations/fr.json index 10796541..a5e493d7 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Impossible de charger les données de l'application. Vérifiez votre connexion et réessayez.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Appliqué", "audio": "Audio", "audioActive": "Audio actif", + "audio_capable": "Compatible audio", "audio_device": "Combiné BT", "audio_output": "Sortie audio", "availableDevices": "Appareils disponibles", @@ -47,6 +49,8 @@ "connect": "Connecter", "connected": "Connecté", "connectionError": "Erreur de connexion", + "connection_error_message": "Impossible de se connecter à l'appareil", + "connection_error_title": "Échec de la connexion", "current_selection": "Sélection actuelle", "device_disconnected": "Appareil déconnecté", "disconnect": "Déconnecter", @@ -104,10 +108,12 @@ "loading": "Chargement...", "no_images": "Aucune image disponible", "no_images_description": "Ajoutez des images à votre appel pour aider à la documentation et à la communication", + "not_signed_in": "Vous devez être connecté pour envoyer des images", "select_from_gallery": "Sélectionner depuis la galerie", "take_photo": "Prendre une photo", "title": "Images de l'appel", - "upload": "Télécharger" + "upload": "Télécharger", + "upload_error": "Erreur lors de l'envoi de l'image" }, "callNotes": { "addNote": "Ajouter une note", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Itinéraire", + "directions_to_destination": "Itinéraire vers la destination", "dispatch_to": "Envoyer à", "dispatch_to_everyone": "Envoyer à tout le personnel disponible", "edit_call": "Modifier l'appel", "edit_call_description": "Mettre à jour les informations de l'appel", + "errors": { + "load_failed": "Échec du chargement des appels" + }, "everyone": "Tout le monde", "field_policy_loading": "Les paramètres d'appel de votre département sont encore en cours de chargement, veuillez réessayer dans un instant", "files": { + "error": "Erreur lors de la récupération des fichiers", + "file_name": "Nom du fichier", "no_files": "Aucun fichier disponible", "no_files_description": "Aucun fichier n'a encore été ajouté à cet appel", + "open_error": "Erreur lors de l'ouverture du fichier", + "share_error": "Erreur lors du partage du fichier", + "sharing_unavailable": "Le partage n'est pas disponible sur cet appareil", "title": "Fichiers de l'appel" }, "geocoding_error": "Échec de la recherche d'adresse, veuillez réessayer", @@ -308,6 +323,7 @@ "users": "Utilisateurs", "viewNotes": "Notes", "view_details": "Voir les détails", + "view_on_map": "Voir sur la carte", "what3words": "what3words", "what3words_found": "Adresse what3words trouvée et localisation mise à jour", "what3words_geocoding_error": "Échec de la recherche de l'adresse what3words, veuillez réessayer", @@ -433,6 +449,7 @@ "add": "Ajouter", "back": "Retour", "cancel": "Annuler", + "clear_search": "Effacer la recherche", "close": "Fermer", "confirm": "Confirmer", "confirm_location": "Confirmer la localisation", @@ -454,6 +471,7 @@ "no_address_found": "Aucune adresse trouvée", "no_location": "Aucune donnée de localisation disponible", "no_results_found": "Aucun résultat trouvé", + "no_unit": "Aucune unité", "no_unit_selected": "Aucune unité sélectionnée", "nothingToDisplay": "Rien à afficher pour le moment", "of": "de", @@ -476,6 +494,7 @@ "tap_map_to_select": "Appuyez sur la carte pour sélectionner une localisation", "tryAgainLater": "Veuillez réessayer plus tard", "unknown": "Inconnu", + "unknown_error": "Une erreur inconnue s'est produite", "upload": "Télécharger", "uploading": "Téléchargement en cours..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Contacts", "twitter": "Twitter", + "unknown_company": "Entreprise inconnue", + "unknown_person": "Personne inconnue", "visibility": "Visibilité", "website": "Site web", "zip": "Code postal" @@ -659,6 +680,7 @@ "message": "Veuillez vérifier votre nom d'utilisateur et votre mot de passe et réessayer.", "title": "Échec de la connexion" }, + "hide_password": "Masquer le mot de passe", "login": "Connexion", "login_button": "Se connecter", "login_button_description": "Connectez-vous à votre compte pour continuer", @@ -667,8 +689,8 @@ "login_button_success": "Connexion réussie", "or_sign_in_with_password": "ou se connecter avec un mot de passe", "password": "Mot de passe", - "password_incorrect": "Le mot de passe était incorrect", "password_placeholder": "Saisir votre mot de passe", + "show_password": "Afficher le mot de passe", "sso_button": "Se connecter avec SSO", "sso_department": "SSO fourni par {{name}}", "sso_department_id": "ID du département (optionnel)", @@ -689,6 +711,7 @@ "call_set_as_current": "Appel défini comme appel actuel", "failed_to_open_maps": "Échec de l'ouverture de l'application de cartes", "failed_to_set_current_call": "Échec de la définition de l'appel comme actuel", + "lock_map": "Verrouiller la carte sur l'unité", "no_location_for_routing": "Aucune donnée de localisation disponible pour la navigation", "pin_address": "Address", "pin_color": "Couleur de l'épingle", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Recentrer la carte", "set_as_current_call": "Définir comme appel actuel", + "unlock_map": "Déverrouiller la carte", "view_call_details": "Voir les détails de l'appel", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Rechercher des notes...", "title": "Notes" }, + "notifications": { + "confirm_delete_message_count": "Voulez-vous vraiment supprimer {{count}} notifications ? Cette action est irréversible.", + "confirm_delete_message_one": "Voulez-vous vraiment supprimer cette notification ? Cette action est irréversible.", + "confirm_delete_title": "Confirmer la suppression", + "delete_selected": "Supprimer les notifications sélectionnées", + "deselect_all": "Tout désélectionner", + "empty": "Aucune mise à jour disponible", + "enter_selection_mode": "Sélectionner des notifications", + "open": "Ouvrir les notifications", + "remove_failed_count": "Échec de la suppression des notifications", + "remove_failed_one": "Échec de la suppression de la notification", + "removed_count": "{{count}} notifications supprimées", + "removed_one": "Notification supprimée", + "select_all": "Tout sélectionner", + "selected_count": "{{count}} sélectionnées", + "title": "Notifications", + "unable_to_load": "Impossible de charger les notifications" + }, "onboarding": { "message": "Bienvenue sur l'application Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Voir l'appel" }, "roles": { + "assignedElsewhere": "Dans un autre rôle", + "clearAssignment": "Effacer l'attribution actuelle", "modal": { "title": "Attributions de rôles d'unité" }, + "noUsersFound": "Aucun utilisateur trouvé", + "save_error": "Erreur lors de l'enregistrement des attributions de rôles", + "saved_successfully": "Attributions de rôles enregistrées", + "searchUsers": "Rechercher par nom ou groupe...", "selectUser": "Sélectionner un utilisateur", + "selectUserDescription": "Choisissez une personne pour ce rôle", + "selectUserForRole": "Attribuer : {{role}}", + "selectUserLabel": "Sélectionner {{name}}", "status": "{{active}} sur {{total}} rôles", + "tapToAssign": "Appuyez pour attribuer un utilisateur à {{role}}", "tap_to_manage": "Appuyer pour gérer les rôles", + "title": "Attribution des rôles de l'unité", "unassigned": "Non attribué" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Distance totale", "total_duration": "Durée totale", "traffic_delay": "{{time}} de retard dû au trafic", + "traffic_heavy": "Trafic dense", + "traffic_light": "Trafic fluide", + "traffic_moderate": "Trafic modéré", + "traffic_no_data": "Aucune donnée de trafic", + "traffic_severe": "Trafic très dense", "try_different_search": "Try a different search term", "type": "Type", "unassigned": "Non assigné", diff --git a/src/translations/it.json b/src/translations/it.json index 21437135..bfd140ae 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Impossibile caricare i dati dell'app. Controlla la connessione e riprova.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Applicato", "audio": "Audio", "audioActive": "Audio attivo", + "audio_capable": "Compatibile con audio", "audio_device": "Auricolare BT", "audio_output": "Uscita audio", "availableDevices": "Dispositivi disponibili", @@ -47,6 +49,8 @@ "connect": "Connetti", "connected": "Connesso", "connectionError": "Errore di connessione", + "connection_error_message": "Impossibile connettersi al dispositivo", + "connection_error_title": "Connessione non riuscita", "current_selection": "Selezione corrente", "device_disconnected": "Dispositivo disconnesso", "disconnect": "Disconnetti", @@ -104,10 +108,12 @@ "loading": "Caricamento...", "no_images": "Nessuna immagine disponibile", "no_images_description": "Aggiungi immagini alla tua chiamata per facilitare la documentazione e la comunicazione", + "not_signed_in": "Devi aver effettuato l'accesso per caricare immagini", "select_from_gallery": "Seleziona dalla galleria", "take_photo": "Scatta foto", "title": "Immagini chiamata", - "upload": "Carica" + "upload": "Carica", + "upload_error": "Errore durante il caricamento dell'immagine" }, "callNotes": { "addNote": "Aggiungi nota", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Indicazioni", + "directions_to_destination": "Indicazioni per la destinazione", "dispatch_to": "Invia a", "dispatch_to_everyone": "Invia a tutto il personale disponibile", "edit_call": "Modifica chiamata", "edit_call_description": "Aggiorna le informazioni della chiamata", + "errors": { + "load_failed": "Impossibile caricare le chiamate" + }, "everyone": "Tutti", "field_policy_loading": "Le impostazioni delle chiamate del tuo dipartimento sono ancora in caricamento, riprova tra un momento", "files": { + "error": "Errore durante il recupero dei file", + "file_name": "Nome del file", "no_files": "Nessun file disponibile", "no_files_description": "Nessun file aggiunto a questa chiamata", + "open_error": "Errore durante l'apertura del file", + "share_error": "Errore durante la condivisione del file", + "sharing_unavailable": "La condivisione non è disponibile su questo dispositivo", "title": "File chiamata" }, "geocoding_error": "Ricerca indirizzo fallita, riprovare", @@ -308,6 +323,7 @@ "users": "Utenti", "viewNotes": "Note", "view_details": "Visualizza dettagli", + "view_on_map": "Visualizza sulla mappa", "what3words": "what3words", "what3words_found": "Indirizzo what3words trovato e posizione aggiornata", "what3words_geocoding_error": "Ricerca indirizzo what3words fallita, riprovare", @@ -433,6 +449,7 @@ "add": "Aggiungi", "back": "Indietro", "cancel": "Annulla", + "clear_search": "Cancella ricerca", "close": "Chiudi", "confirm": "Conferma", "confirm_location": "Conferma posizione", @@ -454,6 +471,7 @@ "no_address_found": "Nessun indirizzo trovato", "no_location": "Nessun dato di posizione disponibile", "no_results_found": "Nessun risultato trovato", + "no_unit": "Nessuna unità", "no_unit_selected": "Nessuna unità selezionata", "nothingToDisplay": "Non c'è nulla da visualizzare al momento", "of": "di", @@ -476,6 +494,7 @@ "tap_map_to_select": "Tocca la mappa per selezionare una posizione", "tryAgainLater": "Riprova più tardi", "unknown": "Sconosciuto", + "unknown_error": "Si è verificato un errore sconosciuto", "upload": "Carica", "uploading": "Caricamento..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Contatti", "twitter": "Twitter", + "unknown_company": "Azienda sconosciuta", + "unknown_person": "Persona sconosciuta", "visibility": "Visibilità", "website": "Sito web", "zip": "CAP" @@ -659,6 +680,7 @@ "message": "Verificare nome utente e password e riprovare.", "title": "Accesso fallito" }, + "hide_password": "Nascondi password", "login": "Accedi", "login_button": "Accedi", "login_button_description": "Accedi al tuo account per continuare", @@ -667,8 +689,8 @@ "login_button_success": "Accesso effettuato con successo", "or_sign_in_with_password": "o accedi con password", "password": "Password", - "password_incorrect": "La password non è corretta", "password_placeholder": "Inserisci la tua password", + "show_password": "Mostra password", "sso_button": "Accedi con SSO", "sso_department": "SSO fornito da {{name}}", "sso_department_id": "ID dipartimento (opzionale)", @@ -689,6 +711,7 @@ "call_set_as_current": "Chiamata impostata come chiamata corrente", "failed_to_open_maps": "Impossibile aprire l'applicazione mappe", "failed_to_set_current_call": "Impossibile impostare la chiamata come corrente", + "lock_map": "Blocca la mappa sull'unità", "no_location_for_routing": "Nessun dato di posizione disponibile per la navigazione", "pin_address": "Address", "pin_color": "Colore pin", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Ricentra mappa", "set_as_current_call": "Imposta come chiamata corrente", + "unlock_map": "Sblocca la mappa", "view_call_details": "Visualizza dettagli chiamata", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Cerca note...", "title": "Note" }, + "notifications": { + "confirm_delete_message_count": "Vuoi davvero eliminare {{count}} notifiche? Questa azione non può essere annullata.", + "confirm_delete_message_one": "Vuoi davvero eliminare questa notifica? Questa azione non può essere annullata.", + "confirm_delete_title": "Conferma l'eliminazione", + "delete_selected": "Elimina le notifiche selezionate", + "deselect_all": "Deseleziona tutto", + "empty": "Nessun aggiornamento disponibile", + "enter_selection_mode": "Seleziona notifiche", + "open": "Apri le notifiche", + "remove_failed_count": "Impossibile eliminare le notifiche", + "remove_failed_one": "Impossibile eliminare la notifica", + "removed_count": "{{count}} notifiche eliminate", + "removed_one": "Notifica eliminata", + "select_all": "Seleziona tutto", + "selected_count": "{{count}} selezionate", + "title": "Notifiche", + "unable_to_load": "Impossibile caricare le notifiche" + }, "onboarding": { "message": "Benvenuto nell'app Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Visualizza chiamata" }, "roles": { + "assignedElsewhere": "In un altro ruolo", + "clearAssignment": "Rimuovi l'assegnazione attuale", "modal": { "title": "Assegnazioni ruoli unità" }, + "noUsersFound": "Nessun utente trovato", + "save_error": "Errore durante il salvataggio delle assegnazioni dei ruoli", + "saved_successfully": "Assegnazioni dei ruoli salvate", + "searchUsers": "Cerca per nome o gruppo...", "selectUser": "Seleziona utente", + "selectUserDescription": "Scegli una persona per questo ruolo", + "selectUserForRole": "Assegna: {{role}}", + "selectUserLabel": "Seleziona {{name}}", "status": "{{active}} di {{total}} ruoli", + "tapToAssign": "Tocca per assegnare un utente a {{role}}", "tap_to_manage": "Tocca per gestire i ruoli", + "title": "Assegnazione dei ruoli dell'unità", "unassigned": "Non assegnato" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Distanza totale", "total_duration": "Durata totale", "traffic_delay": "{{time}} di ritardo a causa del traffico", + "traffic_heavy": "Traffico intenso", + "traffic_light": "Traffico scorrevole", + "traffic_moderate": "Traffico moderato", + "traffic_no_data": "Nessun dato sul traffico", + "traffic_severe": "Traffico molto intenso", "try_different_search": "Try a different search term", "type": "Tipo", "unassigned": "Non assegnato", diff --git a/src/translations/pl.json b/src/translations/pl.json index f3893beb..16203e90 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Nie można załadować danych aplikacji. Sprawdź połączenie i spróbuj ponownie.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Zastosowano", "audio": "Audio", "audioActive": "Audio aktywne", + "audio_capable": "Obsługuje dźwięk", "audio_device": "Słuchawka BT", "audio_output": "Wyjście audio", "availableDevices": "Dostępne urządzenia", @@ -47,6 +49,8 @@ "connect": "Połącz", "connected": "Połączono", "connectionError": "Błąd połączenia", + "connection_error_message": "Nie można połączyć się z urządzeniem", + "connection_error_title": "Połączenie nieudane", "current_selection": "Bieżący wybór", "device_disconnected": "Urządzenie rozłączone", "disconnect": "Rozłącz", @@ -104,10 +108,12 @@ "loading": "Ładowanie...", "no_images": "Brak dostępnych zdjęć", "no_images_description": "Dodaj zdjęcia do zgłoszenia, aby ułatwić dokumentację i komunikację", + "not_signed_in": "Aby przesyłać obrazy, musisz się zalogować", "select_from_gallery": "Wybierz z galerii", "take_photo": "Zrób zdjęcie", "title": "Zdjęcia zgłoszenia", - "upload": "Prześlij" + "upload": "Prześlij", + "upload_error": "Błąd podczas przesyłania obrazu" }, "callNotes": { "addNote": "Dodaj notatkę", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Wskazówki", + "directions_to_destination": "Trasa do celu", "dispatch_to": "Wyślij do", "dispatch_to_everyone": "Wyślij do wszystkich dostępnych", "edit_call": "Edytuj zgłoszenie", "edit_call_description": "Zaktualizuj informacje o zgłoszeniu", + "errors": { + "load_failed": "Nie udało się załadować zgłoszeń" + }, "everyone": "Wszyscy", "field_policy_loading": "Ustawienia zgłoszeń Twojej jednostki są jeszcze wczytywane, spróbuj ponownie za chwilę", "files": { + "error": "Błąd podczas pobierania plików", + "file_name": "Nazwa pliku", "no_files": "Brak dostępnych plików", "no_files_description": "Do tego zgłoszenia nie dodano jeszcze żadnych plików", + "open_error": "Błąd podczas otwierania pliku", + "share_error": "Błąd podczas udostępniania pliku", + "sharing_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu", "title": "Pliki zgłoszenia" }, "geocoding_error": "Wyszukiwanie adresu nie powiodło się, spróbuj ponownie", @@ -308,6 +323,7 @@ "users": "Użytkownicy", "viewNotes": "Notatki", "view_details": "Wyświetl szczegóły", + "view_on_map": "Pokaż na mapie", "what3words": "what3words", "what3words_found": "Adres what3words znaleziony i lokalizacja zaktualizowana", "what3words_geocoding_error": "Wyszukiwanie adresu what3words nie powiodło się, spróbuj ponownie", @@ -433,6 +449,7 @@ "add": "Dodaj", "back": "Wstecz", "cancel": "Anuluj", + "clear_search": "Wyczyść wyszukiwanie", "close": "Zamknij", "confirm": "Potwierdź", "confirm_location": "Potwierdź lokalizację", @@ -454,6 +471,7 @@ "no_address_found": "Nie znaleziono adresu", "no_location": "Brak danych lokalizacji", "no_results_found": "Nie znaleziono wyników", + "no_unit": "Brak jednostki", "no_unit_selected": "Nie wybrano jednostki", "nothingToDisplay": "Nie ma nic do wyświetlenia w tej chwili", "of": "z", @@ -476,6 +494,7 @@ "tap_map_to_select": "Dotknij mapy, aby wybrać lokalizację", "tryAgainLater": "Spróbuj ponownie później", "unknown": "Nieznany", + "unknown_error": "Wystąpił nieznany błąd", "upload": "Prześlij", "uploading": "Przesyłanie..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Kontakty", "twitter": "Twitter", + "unknown_company": "Nieznana firma", + "unknown_person": "Nieznana osoba", "visibility": "Widoczność", "website": "Strona internetowa", "zip": "Kod pocztowy" @@ -659,6 +680,7 @@ "message": "Sprawdź nazwę użytkownika i hasło i spróbuj ponownie.", "title": "Logowanie nieudane" }, + "hide_password": "Ukryj hasło", "login": "Zaloguj się", "login_button": "Zaloguj się", "login_button_description": "Zaloguj się do swojego konta, aby kontynuować", @@ -667,8 +689,8 @@ "login_button_success": "Zalogowano pomyślnie", "or_sign_in_with_password": "lub zaloguj się hasłem", "password": "Hasło", - "password_incorrect": "Hasło jest nieprawidłowe", "password_placeholder": "Wpisz hasło", + "show_password": "Pokaż hasło", "sso_button": "Zaloguj się przez SSO", "sso_department": "SSO zapewnione przez {{name}}", "sso_department_id": "ID działu (opcjonalnie)", @@ -689,6 +711,7 @@ "call_set_as_current": "Zgłoszenie ustawione jako bieżące", "failed_to_open_maps": "Nie udało się otworzyć aplikacji map", "failed_to_set_current_call": "Nie udało się ustawić zgłoszenia jako bieżące", + "lock_map": "Zablokuj mapę na jednostce", "no_location_for_routing": "Brak danych lokalizacji do nawigacji", "pin_address": "Address", "pin_color": "Kolor pinezki", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Wyśrodkuj mapę", "set_as_current_call": "Ustaw jako bieżące zgłoszenie", + "unlock_map": "Odblokuj mapę", "view_call_details": "Wyświetl szczegóły zgłoszenia", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Szukaj notatek...", "title": "Notatki" }, + "notifications": { + "confirm_delete_message_count": "Czy na pewno chcesz usunąć powiadomienia w liczbie {{count}}? Tej operacji nie można cofnąć.", + "confirm_delete_message_one": "Czy na pewno chcesz usunąć to powiadomienie? Tej operacji nie można cofnąć.", + "confirm_delete_title": "Potwierdź usunięcie", + "delete_selected": "Usuń zaznaczone powiadomienia", + "deselect_all": "Odznacz wszystko", + "empty": "Brak nowych informacji", + "enter_selection_mode": "Zaznacz powiadomienia", + "open": "Otwórz powiadomienia", + "remove_failed_count": "Nie udało się usunąć powiadomień", + "remove_failed_one": "Nie udało się usunąć powiadomienia", + "removed_count": "Usunięto powiadomienia: {{count}}", + "removed_one": "Powiadomienie usunięte", + "select_all": "Zaznacz wszystko", + "selected_count": "Zaznaczono: {{count}}", + "title": "Powiadomienia", + "unable_to_load": "Nie można załadować powiadomień" + }, "onboarding": { "message": "Witamy w aplikacji Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Wyświetl zgłoszenie" }, "roles": { + "assignedElsewhere": "W innej funkcji", + "clearAssignment": "Wyczyść bieżący przydział", "modal": { "title": "Przypisania ról jednostki" }, + "noUsersFound": "Nie znaleziono użytkowników", + "save_error": "Błąd podczas zapisywania przydziału funkcji", + "saved_successfully": "Zapisano przydział funkcji", + "searchUsers": "Szukaj według nazwiska lub grupy...", "selectUser": "Wybierz użytkownika", + "selectUserDescription": "Wybierz osobę do tej funkcji", + "selectUserForRole": "Przypisz: {{role}}", + "selectUserLabel": "Wybierz: {{name}}", "status": "{{active}} z {{total}} ról", + "tapToAssign": "Dotknij, aby przypisać użytkownika do: {{role}}", "tap_to_manage": "Dotknij, aby zarządzać rolami", + "title": "Przydział funkcji w jednostce", "unassigned": "Nieprzypisany" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Całkowita odległość", "total_duration": "Całkowity czas", "traffic_delay": "{{time}} opóźnienia z powodu ruchu drogowego", + "traffic_heavy": "Duży ruch", + "traffic_light": "Mały ruch", + "traffic_moderate": "Umiarkowany ruch", + "traffic_no_data": "Brak danych o ruchu", + "traffic_severe": "Bardzo duży ruch", "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Nieprzypisany", diff --git a/src/translations/sv.json b/src/translations/sv.json index 9e2762f9..a33cb8be 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Det gick inte att läsa in appdata. Kontrollera din anslutning och försök igen.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Tillämpad", "audio": "Ljud", "audioActive": "Ljud aktivt", + "audio_capable": "Ljudkapabel", "audio_device": "BT-handset", "audio_output": "Ljudutgång", "availableDevices": "Tillgängliga enheter", @@ -47,6 +49,8 @@ "connect": "Anslut", "connected": "Ansluten", "connectionError": "Anslutningsfel", + "connection_error_message": "Det gick inte att ansluta till enheten", + "connection_error_title": "Anslutningen misslyckades", "current_selection": "Aktuellt val", "device_disconnected": "Enhet frånkopplad", "disconnect": "Koppla från", @@ -104,10 +108,12 @@ "loading": "Laddar...", "no_images": "Inga bilder tillgängliga", "no_images_description": "Lägg till bilder i ditt samtal för att hjälpa med dokumentation och kommunikation", + "not_signed_in": "Du måste vara inloggad för att ladda upp bilder", "select_from_gallery": "Välj från galleriet", "take_photo": "Ta foto", "title": "Samtalsbilder", - "upload": "Ladda upp" + "upload": "Ladda upp", + "upload_error": "Det gick inte att ladda upp bilden" }, "callNotes": { "addNote": "Lägg till anteckning", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Vägbeskrivning", + "directions_to_destination": "Vägbeskrivning till destinationen", "dispatch_to": "Skicka till", "dispatch_to_everyone": "Skicka till all tillgänglig personal", "edit_call": "Redigera samtal", "edit_call_description": "Uppdatera samtalsinformation", + "errors": { + "load_failed": "Det gick inte att läsa in samtalen" + }, "everyone": "Alla", "field_policy_loading": "Din avdelnings samtalsinställningar laddas fortfarande, försök igen om ett ögonblick", "files": { + "error": "Det gick inte att hämta filerna", + "file_name": "Filnamn", "no_files": "Inga filer tillgängliga", "no_files_description": "Inga filer har lagts till i detta samtal ännu", + "open_error": "Det gick inte att öppna filen", + "share_error": "Det gick inte att dela filen", + "sharing_unavailable": "Delning är inte tillgängligt på den här enheten", "title": "Samtalsfiler" }, "geocoding_error": "Det gick inte att söka efter adressen, försök igen", @@ -308,6 +323,7 @@ "users": "Användare", "viewNotes": "Anteckningar", "view_details": "Visa detaljer", + "view_on_map": "Visa på kartan", "what3words": "what3words", "what3words_found": "what3words-adress hittades och platsen uppdaterades", "what3words_geocoding_error": "Det gick inte att söka efter what3words-adress, försök igen", @@ -433,6 +449,7 @@ "add": "Lägg till", "back": "Tillbaka", "cancel": "Avbryt", + "clear_search": "Rensa sökning", "close": "Stäng", "confirm": "Bekräfta", "confirm_location": "Bekräfta plats", @@ -454,6 +471,7 @@ "no_address_found": "Ingen adress hittades", "no_location": "Ingen platsdata tillgänglig", "no_results_found": "Inga resultat hittades", + "no_unit": "Ingen enhet", "no_unit_selected": "Ingen enhet vald", "nothingToDisplay": "Det finns inget att visa för tillfället", "of": "av", @@ -476,6 +494,7 @@ "tap_map_to_select": "Tryck på kartan för att välja en plats", "tryAgainLater": "Försök igen senare", "unknown": "Okänd", + "unknown_error": "Ett okänt fel uppstod", "upload": "Ladda upp", "uploading": "Laddar upp..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Kontakter", "twitter": "Twitter", + "unknown_company": "Okänt företag", + "unknown_person": "Okänd person", "visibility": "Synlighet", "website": "Webbplats", "zip": "Postnummer" @@ -659,6 +680,7 @@ "message": "Kontrollera ditt användarnamn och lösenord och försök igen.", "title": "Inloggningen misslyckades" }, + "hide_password": "Dölj lösenord", "login": "Logga in", "login_button": "Logga in", "login_button_description": "Logga in på ditt konto för att fortsätta", @@ -667,8 +689,8 @@ "login_button_success": "Inloggningen lyckades", "or_sign_in_with_password": "eller logga in med lösenord", "password": "Lösenord", - "password_incorrect": "Lösenordet var felaktigt", "password_placeholder": "Ange ditt lösenord", + "show_password": "Visa lösenord", "sso_button": "Logga in med SSO", "sso_department": "SSO tillhandahållet av {{name}}", "sso_department_id": "Avdelnings-ID (valfritt)", @@ -689,6 +711,7 @@ "call_set_as_current": "Samtal angett som aktuellt samtal", "failed_to_open_maps": "Det gick inte att öppna kartappen", "failed_to_set_current_call": "Det gick inte att ange samtalet som aktuellt", + "lock_map": "Lås kartan till enheten", "no_location_for_routing": "Ingen platsdata tillgänglig för navigering", "pin_address": "Address", "pin_color": "Nålfärg", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Centrera kartan", "set_as_current_call": "Ange som aktuellt samtal", + "unlock_map": "Lås upp kartan", "view_call_details": "Visa samtalsdetaljer", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Sök anteckningar...", "title": "Anteckningar" }, + "notifications": { + "confirm_delete_message_count": "Är du säker på att du vill ta bort {{count}} aviseringar? Åtgärden kan inte ångras.", + "confirm_delete_message_one": "Är du säker på att du vill ta bort den här aviseringen? Åtgärden kan inte ångras.", + "confirm_delete_title": "Bekräfta borttagning", + "delete_selected": "Ta bort markerade aviseringar", + "deselect_all": "Avmarkera alla", + "empty": "Inga uppdateringar tillgängliga", + "enter_selection_mode": "Markera aviseringar", + "open": "Öppna aviseringar", + "remove_failed_count": "Det gick inte att ta bort aviseringarna", + "remove_failed_one": "Det gick inte att ta bort aviseringen", + "removed_count": "{{count}} aviseringar har tagits bort", + "removed_one": "Aviseringen har tagits bort", + "select_all": "Markera alla", + "selected_count": "{{count}} markerade", + "title": "Aviseringar", + "unable_to_load": "Det gick inte att läsa in aviseringarna" + }, "onboarding": { "message": "Välkommen till Resgrid Unit app site" }, @@ -782,12 +824,23 @@ "view_call": "Visa samtal" }, "roles": { + "assignedElsewhere": "I en annan roll", + "clearAssignment": "Rensa den aktuella tilldelningen", "modal": { "title": "Enhetsrolltilldelningar" }, + "noUsersFound": "Inga användare hittades", + "save_error": "Det gick inte att spara rolltilldelningarna", + "saved_successfully": "Rolltilldelningarna har sparats", + "searchUsers": "Sök efter namn eller grupp...", "selectUser": "Välj användare", + "selectUserDescription": "Välj en person för den här rollen", + "selectUserForRole": "Tilldela: {{role}}", + "selectUserLabel": "Välj {{name}}", "status": "{{active}} av {{total}} roller", + "tapToAssign": "Tryck för att tilldela en användare till {{role}}", "tap_to_manage": "Tryck för att hantera roller", + "title": "Rolltilldelning för enheten", "unassigned": "Otilldelad" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Totalt avstånd", "total_duration": "Total varaktighet", "traffic_delay": "{{time}} försening på grund av trafik", + "traffic_heavy": "Tät trafik", + "traffic_light": "Lite trafik", + "traffic_moderate": "Måttlig trafik", + "traffic_no_data": "Inga trafikdata", + "traffic_severe": "Mycket tät trafik", "try_different_search": "Try a different search term", "type": "Typ", "unassigned": "Ej tilldelad", diff --git a/src/translations/uk.json b/src/translations/uk.json index 4520041f..10412853 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -8,6 +8,7 @@ } }, "app": { + "initialization_failed": "Не вдалося завантажити дані застосунку. Перевірте з'єднання та повторіть спробу.", "title": "Resgrid Unit" }, "audio_streams": { @@ -36,6 +37,7 @@ "applied": "Застосовано", "audio": "Аудіо", "audioActive": "Аудіо активне", + "audio_capable": "Підтримує аудіо", "audio_device": "BT-гарнітура", "audio_output": "Виведення звуку", "availableDevices": "Доступні пристрої", @@ -47,6 +49,8 @@ "connect": "Підключити", "connected": "Підключено", "connectionError": "Помилка підключення", + "connection_error_message": "Не вдалося під'єднатися до пристрою", + "connection_error_title": "Не вдалося з'єднатися", "current_selection": "Поточний вибір", "device_disconnected": "Пристрій відключено", "disconnect": "Відключити", @@ -104,10 +108,12 @@ "loading": "Завантаження...", "no_images": "Немає доступних зображень", "no_images_description": "Додайте зображення до виклику для документування та комунікації", + "not_signed_in": "Щоб завантажувати зображення, потрібно увійти", "select_from_gallery": "Вибрати з галереї", "take_photo": "Зробити фото", "title": "Зображення виклику", - "upload": "Завантажити" + "upload": "Завантажити", + "upload_error": "Помилка завантаження зображення" }, "callNotes": { "addNote": "Додати примітку", @@ -241,15 +247,24 @@ "destination_poi": "Destination POI", "destination_poi_none": "No destination selected", "directions": "Маршрут", + "directions_to_destination": "Маршрут до призначення", "dispatch_to": "Відправити до", "dispatch_to_everyone": "Відправити до всього доступного персоналу", "edit_call": "Редагувати виклик", "edit_call_description": "Оновити інформацію про виклик", + "errors": { + "load_failed": "Не вдалося завантажити виклики" + }, "everyone": "Всі", "field_policy_loading": "Налаштування викликів вашого відділу ще завантажуються, спробуйте ще раз за мить", "files": { + "error": "Помилка отримання файлів", + "file_name": "Назва файлу", "no_files": "Немає доступних файлів", "no_files_description": "До цього виклику ще не додано файлів", + "open_error": "Помилка відкриття файлу", + "share_error": "Помилка надсилання файлу", + "sharing_unavailable": "Надсилання недоступне на цьому пристрої", "title": "Файли виклику" }, "geocoding_error": "Пошук адреси не вдався, спробуйте ще раз", @@ -308,6 +323,7 @@ "users": "Користувачі", "viewNotes": "Примітки", "view_details": "Переглянути деталі", + "view_on_map": "Показати на карті", "what3words": "what3words", "what3words_found": "Адресу what3words знайдено та місцезнаходження оновлено", "what3words_geocoding_error": "Пошук адреси what3words не вдався, спробуйте ще раз", @@ -433,6 +449,7 @@ "add": "Додати", "back": "Назад", "cancel": "Скасувати", + "clear_search": "Очистити пошук", "close": "Закрити", "confirm": "Підтвердити", "confirm_location": "Підтвердити місцезнаходження", @@ -454,6 +471,7 @@ "no_address_found": "Адресу не знайдено", "no_location": "Немає даних про місцезнаходження", "no_results_found": "Результатів не знайдено", + "no_unit": "Немає підрозділу", "no_unit_selected": "Підрозділ не вибрано", "nothingToDisplay": "Наразі нічого не відображати", "of": "з", @@ -476,6 +494,7 @@ "tap_map_to_select": "Торкніться карти для вибору місцезнаходження", "tryAgainLater": "Спробуйте ще раз пізніше", "unknown": "Невідомо", + "unknown_error": "Сталася невідома помилка", "upload": "Завантажити", "uploading": "Завантаження..." }, @@ -555,6 +574,8 @@ "threads": "Threads", "title": "Контакти", "twitter": "Twitter", + "unknown_company": "Невідома компанія", + "unknown_person": "Невідома особа", "visibility": "Видимість", "website": "Веб-сайт", "zip": "Поштовий індекс" @@ -659,6 +680,7 @@ "message": "Перевірте ім'я користувача та пароль і спробуйте ще раз.", "title": "Помилка входу" }, + "hide_password": "Приховати пароль", "login": "Увійти", "login_button": "Увійти", "login_button_description": "Увійдіть до свого облікового запису для продовження", @@ -667,8 +689,8 @@ "login_button_success": "Вхід виконано успішно", "or_sign_in_with_password": "або увійдіть з паролем", "password": "Пароль", - "password_incorrect": "Пароль невірний", "password_placeholder": "Введіть пароль", + "show_password": "Показати пароль", "sso_button": "Увійти через SSO", "sso_department": "SSO надано {{name}}", "sso_department_id": "ID підрозділу (необов'язково)", @@ -689,6 +711,7 @@ "call_set_as_current": "Виклик встановлено як поточний", "failed_to_open_maps": "Не вдалося відкрити додаток карт", "failed_to_set_current_call": "Не вдалося встановити виклик як поточний", + "lock_map": "Закріпити карту на підрозділі", "no_location_for_routing": "Немає даних про місцезнаходження для навігації", "pin_address": "Address", "pin_color": "Колір мітки", @@ -696,6 +719,7 @@ "pin_type": "POI Type", "recenter_map": "Відцентрувати карту", "set_as_current_call": "Встановити як поточний виклик", + "unlock_map": "Відкріпити карту", "view_call_details": "Переглянути деталі виклику", "view_poi_details": "View POI Details" }, @@ -750,6 +774,24 @@ "search": "Пошук приміток...", "title": "Примітки" }, + "notifications": { + "confirm_delete_message_count": "Ви впевнені, що хочете видалити сповіщення ({{count}})? Цю дію не можна скасувати.", + "confirm_delete_message_one": "Ви впевнені, що хочете видалити це сповіщення? Цю дію не можна скасувати.", + "confirm_delete_title": "Підтвердити видалення", + "delete_selected": "Видалити вибрані сповіщення", + "deselect_all": "Зняти вибір", + "empty": "Немає оновлень", + "enter_selection_mode": "Вибрати сповіщення", + "open": "Відкрити сповіщення", + "remove_failed_count": "Не вдалося видалити сповіщення", + "remove_failed_one": "Не вдалося видалити сповіщення", + "removed_count": "Видалено сповіщень: {{count}}", + "removed_one": "Сповіщення видалено", + "select_all": "Вибрати все", + "selected_count": "Вибрано: {{count}}", + "title": "Сповіщення", + "unable_to_load": "Не вдалося завантажити сповіщення" + }, "onboarding": { "message": "Ласкаво просимо до додатку Resgrid Unit" }, @@ -782,12 +824,23 @@ "view_call": "Переглянути виклик" }, "roles": { + "assignedElsewhere": "В іншій ролі", + "clearAssignment": "Очистити поточне призначення", "modal": { "title": "Призначення ролей підрозділу" }, + "noUsersFound": "Користувачів не знайдено", + "save_error": "Помилка збереження призначень ролей", + "saved_successfully": "Призначення ролей збережено", + "searchUsers": "Пошук за іменем або групою...", "selectUser": "Вибрати користувача", + "selectUserDescription": "Виберіть людину на цю роль", + "selectUserForRole": "Призначити: {{role}}", + "selectUserLabel": "Вибрати {{name}}", "status": "{{active}} з {{total}} ролей", + "tapToAssign": "Торкніться, щоб призначити користувача на {{role}}", "tap_to_manage": "Торкніться для управління ролями", + "title": "Призначення ролей підрозділу", "unassigned": "Не призначено" }, "routes": { @@ -929,6 +982,11 @@ "total_distance": "Загальна відстань", "total_duration": "Загальний час", "traffic_delay": "{{time}} затримки через трафік", + "traffic_heavy": "Щільний рух", + "traffic_light": "Вільний рух", + "traffic_moderate": "Помірний рух", + "traffic_no_data": "Немає даних про рух", + "traffic_severe": "Дуже щільний рух", "try_different_search": "Try a different search term", "type": "Тип", "unassigned": "Не призначено", diff --git a/src/utils/__tests__/strip-html.test.ts b/src/utils/__tests__/strip-html.test.ts new file mode 100644 index 00000000..c394c19e --- /dev/null +++ b/src/utils/__tests__/strip-html.test.ts @@ -0,0 +1,56 @@ +import { stripHtml } from '../strip-html'; + +describe('stripHtml', () => { + it('returns an empty string for null, undefined and empty input', () => { + expect(stripHtml(null)).toBe(''); + expect(stripHtml(undefined)).toBe(''); + expect(stripHtml('')).toBe(''); + }); + + it('returns plain text unchanged', () => { + expect(stripHtml('Structure fire at main street')).toBe('Structure fire at main street'); + }); + + it('strips simple tags', () => { + expect(stripHtml('

Structure fire

')).toBe('Structure fire'); + expect(stripHtml('
MVA with injuries
')).toBe('MVA with injuries'); + }); + + it('inserts whitespace where tags separated words', () => { + expect(stripHtml('

Line one

Line two

')).toBe('Line one Line two'); + expect(stripHtml('First
Second')).toBe('First Second'); + }); + + it('decodes common named entities', () => { + expect(stripHtml('

Smoke & flames

')).toBe('Smoke & flames'); + expect(stripHtml('A B"C"')).toBe('A B"C"'); + }); + + it('decodes entity-encoded markup before stripping, so encoded tags are removed too', () => { + // Matches the sanitizer's decode-then-process order: a field that arrives fully + // entity-encoded is real markup, not literal angle-bracket text. + expect(stripHtml('Smoke & flames <visible>')).toBe('Smoke & flames'); + }); + + it('decodes numeric entities', () => { + expect(stripHtml('Temp – 40°')).toBe('Temp – 40°'); + expect(stripHtml('AB')).toBe('AB'); + }); + + it('collapses runs of whitespace and trims', () => { + expect(stripHtml('

Fire \n\t alarm

')).toBe('Fire alarm'); + }); + + it('removes script and style blocks including their content', () => { + expect(stripHtml('

Safe

')).toBe('Safe'); + }); + + it('handles entity-encoded markup (no literal tags in the input)', () => { + expect(stripHtml('<p>Encoded nature</p>')).toBe('Encoded nature'); + }); + + it('handles attributes and self-closing tags', () => { + expect(stripHtml('Link text')).toBe('Link text'); + expect(stripHtml('picafter')).toBe('after'); + }); +}); diff --git a/src/utils/strip-html.ts b/src/utils/strip-html.ts new file mode 100644 index 00000000..f5b3cc10 --- /dev/null +++ b/src/utils/strip-html.ts @@ -0,0 +1,25 @@ +import { decodeHtmlEntities, decodeHtmlEntitiesIfEncoded } from './html-entities'; + +/** + * Converts an HTML fragment to plain text for previews (e.g. call Nature inside + * list cards): drops script/style blocks, strips tags, decodes common entities + * and collapses whitespace to single spaces. + * + * Rendering these fields through the WebView-backed HtmlRenderer is reserved for + * detail screens — a WebView per list row is far too heavy. + */ +export const stripHtml = (html: string | null | undefined): string => { + if (!html) { + return ''; + } + + // Some fields arrive entity-encoded (`<p>…`) — decode first so the tag + // stripper below sees real markup instead of literal `<p>` text. + const decodedMarkup = decodeHtmlEntitiesIfEncoded(html); + + const withoutBlocks = decodedMarkup.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' '); + const withoutTags = withoutBlocks.replace(/<[^>]*>/g, ' '); + const decodedText = decodeHtmlEntities(withoutTags); + + return decodedText.replace(/\s+/g, ' ').trim(); +}; diff --git a/theme-tokens.css b/theme-tokens.css index 3b21a520..8bbcad96 100644 --- a/theme-tokens.css +++ b/theme-tokens.css @@ -692,23 +692,6 @@ --shadow-soft-4: 0px 0px 40px rgba(38, 38, 38, 0.1); } -/* Web-only pulse animation for user location marker on map */ -@keyframes pulse-ring { - 0%, 100% { - transform: scale(1); - opacity: 0.3; - } - 50% { - transform: scale(1.2); - opacity: 0.15; - } -} - -/* Web-only: applied to the location marker outer ring (replaces Animated.loop) */ -.pulse-ring { - animation: pulse-ring 2s ease-in-out infinite; -} - /* Web-only skeleton loading animation */ @keyframes skeleton-pulse { 0%, 100% {