diff --git a/scripts/__tests__/extract-release-notes.test.ts b/scripts/__tests__/extract-release-notes.test.ts new file mode 100644 index 0000000..27e3afa --- /dev/null +++ b/scripts/__tests__/extract-release-notes.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh'); + +const extractReleaseNotes = (body: string): string => { + const result = spawnSync('bash', [scriptPath, '--extract-only'], { + encoding: 'utf8', + input: body, + }); + + if (result.status !== 0) { + throw new Error(result.stderr); + } + + return result.stdout.trim(); +}; + +describe('extract-release-notes', () => { + it.each(['##', '###'])('normalizes a %s PR Description heading', (heading) => { + const notes = extractReleaseNotes(`${heading} PR Description\n\nAdds the release change.`); + + expect(notes).toBe(`${heading} Description\nAdds the release change.`); + expect(notes).not.toContain('PR Description'); + }); + + it('continues to prefer the dedicated Release Notes section', () => { + const notes = extractReleaseNotes('## PR Description\nInternal PR context\n\n## Release Notes\nUser-facing change\n\n## Testing\nPassed'); + + expect(notes).toBe('User-facing change'); + }); +}); diff --git a/scripts/extract-release-notes.sh b/scripts/extract-release-notes.sh index 50014e6..671a68f 100755 --- a/scripts/extract-release-notes.sh +++ b/scripts/extract-release-notes.sh @@ -11,11 +11,12 @@ export GH_TOKEN=$5 # Function to extract release notes from PR body extract_release_notes() { local body="$1" - + # First pass: Remove everything between CodeRabbit comment markers using sed - local cleaned_body="$(printf '%s\n' "$body" \ + local cleaned_body + cleaned_body="$(printf '%s\n' "$body" \ | sed '//,//d')" - + # Second pass: Remove the "Summary by CodeRabbit" section cleaned_body="$(printf '%s\n' "$cleaned_body" \ | awk ' @@ -24,34 +25,49 @@ extract_release_notes() { /^## / && skip==1 { skip=0 } skip==0 { print } ')" - - # Third pass: Remove any remaining HTML comment lines + + # Third pass: Replace PR-only description headings with release-friendly headings + cleaned_body="$(printf '%s\n' "$cleaned_body" \ + | awk ' + /^#+[[:space:]]+PR Description[[:space:]]*$/ { + sub(/PR Description[[:space:]]*$/, "Description") + } + { print } + ')" + + # Fourth pass: Remove any remaining HTML comment lines cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | sed '/^$/d')" - - # Fourth pass: Remove specific CodeRabbit lines + + # Fifth pass: Remove specific CodeRabbit lines cleaned_body="$(printf '%s\n' "$cleaned_body" \ | (grep -v '✏️ Tip: You can customize this high-level summary in your review settings\.' || true) \ | (grep -v '' || true) \ | (grep -v '' || true))" - - # Fifth pass: Trim leading and trailing whitespace/empty lines + + # Sixth pass: Trim leading and trailing whitespace/empty lines cleaned_body="$(printf '%s\n' "$cleaned_body" | sed '/^$/d' | awk 'NF {p=1} p')" - + # Try to extract content under "## Release Notes" heading if it exists - local notes="$(printf '%s\n' "$cleaned_body" \ + local notes + notes="$(printf '%s\n' "$cleaned_body" \ | awk 'f && /^## /{exit} /^## Release Notes/{f=1; next} f')" - + # If no specific "Release Notes" section found, use the entire cleaned body if [ -z "$notes" ]; then notes="$cleaned_body" fi - + # Final trim notes="$(printf '%s\n' "$notes" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" - + printf '%s\n' "$notes" } +if [ "${1:-}" = "--extract-only" ]; then + extract_release_notes "$(cat)" + exit 0 +fi + # Determine source of release notes NOTES="" diff --git a/src/api/common/client.tsx b/src/api/common/client.tsx index 92d2005..e614c0e 100644 --- a/src/api/common/client.tsx +++ b/src/api/common/client.tsx @@ -1,6 +1,6 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, isAxiosError } from 'axios'; -import { refreshTokenRequest } from '@/lib/auth/api'; +import { refreshTokenSingleFlight } from '@/lib/auth/api'; import { logger } from '@/lib/logging'; import { getBaseApiUrl } from '@/lib/storage/app'; import useAuthStore from '@/stores/auth/store'; @@ -8,6 +8,7 @@ import useAuthStore from '@/stores/auth/store'; // Create axios instance with default config const axiosInstance: AxiosInstance = axios.create({ baseURL: getBaseApiUrl(), + timeout: 15000, headers: { 'Content-Type': 'application/json', }, @@ -83,7 +84,7 @@ axiosInstance.interceptors.response.use( throw new Error('No refresh token available'); } - const response = await refreshTokenRequest(refreshToken); + const response = await refreshTokenSingleFlight(refreshToken); const { access_token, refresh_token: newRefreshToken } = response; // Update tokens in store diff --git a/src/app/(app)/__tests__/incidents.test.tsx b/src/app/(app)/__tests__/incidents.test.tsx new file mode 100644 index 0000000..cbcf08d --- /dev/null +++ b/src/app/(app)/__tests__/incidents.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; +import React from 'react'; + +import { type IncidentCommandSummary } from '@/models/v4/incidentCommand/incidentCommandModels'; +import { useIncidentsStore } from '@/stores/command/incidents-store'; + +jest.mock('@react-navigation/native', () => ({ + useFocusEffect: jest.fn(), +})); + +jest.mock('expo-router', () => ({ + router: { push: jest.fn() }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('@/components/ui/focus-aware-status-bar', () => ({ + FocusAwareStatusBar: () => null, +})); + +jest.mock('@/components/command/incident-card', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + IncidentCard: ({ summary }: { summary: { CallName?: string | null; IncidentCommandId: string; Name?: string | null } }) => + React.createElement(Text, { testID: `incident-summary-${summary.IncidentCommandId}` }, summary.Name ?? summary.CallName), + }; +}); + +jest.mock('@/components/common/loading', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + Loading: ({ text }: { text: string }) => React.createElement(Text, { testID: 'loading' }, text), + }; +}); + +jest.mock('@/components/common/zero-state', () => { + const React = require('react'); + const { Text, View } = require('react-native'); + + return { + __esModule: true, + default: ({ description, heading }: { description: string; heading: string }) => + React.createElement(View, { testID: 'zero-state' }, React.createElement(Text, null, heading), React.createElement(Text, null, description)), + }; +}); + +jest.mock('@/components/ui/flat-list', () => { + const React = require('react'); + const { View } = require('react-native'); + + return { + FlatList: ({ + data, + keyExtractor, + ListEmptyComponent, + renderItem, + testID, + }: { + data: { IncidentCommandId: string }[]; + keyExtractor: (item: { IncidentCommandId: string }) => string; + ListEmptyComponent?: unknown; + renderItem: (info: { item: { IncidentCommandId: string } }) => unknown; + testID?: string; + }) => React.createElement(View, { testID }, data.length > 0 ? data.map((item) => React.createElement(View, { key: keyExtractor(item) }, renderItem({ item }) as never)) : (ListEmptyComponent as never)), + }; +}); + +import IncidentsScreen from '../incidents'; + +const summaries: IncidentCommandSummary[] = [ + { + IncidentCommandId: 'ic-1', + DepartmentId: 1, + CallId: 101, + Name: 'Warehouse Fire', + CallName: 'Commercial Structure Fire', + CallNumber: 'C-1001', + CallAddress: '100 Industrial Way', + Status: 0, + EstablishedOn: '2026-07-25T10:00:00Z', + CommanderName: 'Alex Rivera', + CommandPostLocationText: 'North entrance', + AssignedPersonnelCount: 8, + AssignedUnitCount: 3, + }, + { + IncidentCommandId: 'ic-2', + DepartmentId: 1, + CallId: 202, + Name: 'Flood Response', + CallName: 'Flooding', + CallNumber: 'C-2002', + CallAddress: '200 River Road', + Status: 0, + EstablishedOn: '2026-07-25T11:00:00Z', + CommanderName: 'Jordan Lee', + CommandPostLocationText: 'Community center', + AssignedPersonnelCount: 5, + AssignedUnitCount: 2, + }, +]; + +describe('IncidentsScreen search', () => { + beforeEach(() => { + useIncidentsStore.setState({ + summaries, + includeClosed: false, + isLoading: false, + error: null, + }); + }); + + it('filters incidents by searchable summary fields', () => { + const { unmount } = render(); + + fireEvent.changeText(screen.getByTestId('incidents-search'), 'C-2002'); + + expect(screen.queryByTestId('incident-summary-ic-1')).toBeNull(); + expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy(); + unmount(); + }); + + it('clears the search and restores the incident list', () => { + const { unmount } = render(); + + fireEvent.changeText(screen.getByTestId('incidents-search'), 'warehouse'); + expect(screen.queryByTestId('incident-summary-ic-2')).toBeNull(); + + fireEvent.press(screen.getByTestId('incidents-search-clear')); + + expect(screen.getByTestId('incident-summary-ic-1')).toBeTruthy(); + expect(screen.getByTestId('incident-summary-ic-2')).toBeTruthy(); + unmount(); + }); + + it('shows a search-specific empty state when no incidents match', () => { + const { unmount } = render(); + + fireEvent.changeText(screen.getByTestId('incidents-search'), 'not-a-real-incident'); + + expect(screen.getByText('common.no_results_found')).toBeTruthy(); + expect(screen.getByText('incidents.no_search_results_description')).toBeTruthy(); + unmount(); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 34a77c5..a4c8b3c 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -3,7 +3,7 @@ import { NovuProvider } from '@novu/react-native'; import Countly from 'countly-sdk-react-native-bridge'; import * as NavigationBar from 'expo-navigation-bar'; -import { Redirect, router, SplashScreen, Tabs } from 'expo-router'; +import { Redirect, router, SplashScreen, Tabs, usePathname } from 'expo-router'; import { ArrowLeft, ClipboardList, CloudAlert, LayoutDashboard, Map, Megaphone, Menu, Navigation, Settings } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,6 +26,7 @@ import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { getAppHeaderHeight } from '@/lib/app-shell-layout'; import { useAuthStore } from '@/lib/auth'; import { logger } from '@/lib/logging'; +import { getMapsHeaderState } from '@/lib/maps-route'; import { useIsFirstTime } from '@/lib/storage'; import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultData'; import { audioService } from '@/services/audio.service'; @@ -51,6 +52,8 @@ export default function TabLayout() { const { width, height } = useWindowDimensions(); const insets = useSafeAreaInsets(); const isLandscape = width > height; + const pathname = usePathname(); + const mapsHeaderState = useMemo(() => getMapsHeaderState(pathname), [pathname]); const { isActive, appState } = useAppLifecycle(); const { trackEvent } = useAnalytics(); @@ -429,6 +432,20 @@ export default function TabLayout() { [t, weatherAlertsIcon, headerLeftMap, headerRightNotification] ); + // The Maps browser is a hidden tab route so its entire nested stack retains the + // authenticated app shell. The landing page gets the drawer menu; child routes + // get a back button while the tab bar remains available. + const mapsOptions = useMemo( + () => ({ + href: null, + title: t(mapsHeaderState.titleKey), + headerShown: true as const, + headerLeft: mapsHeaderState.showMenu ? headerLeftMap : headerLeftBack, + headerRight: headerRightNotification, + }), + [t, mapsHeaderState, headerLeftMap, headerLeftBack, headerRightNotification] + ); + // POI detail renders inside the tab shell (app header + tab bar stay visible) but has no tab // bar entry of its own; the header shows a back button instead of the drawer menu. const poiDetailOptions = useMemo( @@ -504,6 +521,8 @@ export default function TabLayout() { tab bar (href: null). IC shell shows: Map, Calls, Settings. */} + + diff --git a/src/app/(app)/incidents.tsx b/src/app/(app)/incidents.tsx index 2e2f9bb..a724cb8 100644 --- a/src/app/(app)/incidents.tsx +++ b/src/app/(app)/incidents.tsx @@ -1,7 +1,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { router } from 'expo-router'; -import { LayoutDashboard } from 'lucide-react-native'; -import React, { useCallback } from 'react'; +import { LayoutDashboard, Search, X } from 'lucide-react-native'; +import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshControl, View } from 'react-native'; @@ -12,11 +12,18 @@ import { Box } from '@/components/ui/box'; import { FlatList } from '@/components/ui/flat-list'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; +import { Input, InputField, InputIcon, InputSlot } from '@/components/ui/input'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { type IncidentCommandSummary } from '@/models/v4/incidentCommand/incidentCommandModels'; import { useIncidentsStore } from '@/stores/command/incidents-store'; +const matchesIncidentSearch = (summary: IncidentCommandSummary, normalizedSearch: string): boolean => { + const searchableValues = [summary.CallId.toString(), summary.CallNumber, summary.Name, summary.CallName, summary.CallAddress, summary.CommanderName, summary.CommandPostLocationText]; + + return searchableValues.some((value) => value?.toLocaleLowerCase().includes(normalizedSearch)); +}; + export default function Incidents() { const { t } = useTranslation(); const summaries = useIncidentsStore((state) => state.summaries); @@ -25,6 +32,10 @@ export default function Incidents() { const error = useIncidentsStore((state) => state.error); const fetchIncidents = useIncidentsStore((state) => state.fetchIncidents); const setIncludeClosed = useIncidentsStore((state) => state.setIncludeClosed); + const [searchQuery, setSearchQuery] = useState(''); + + const normalizedSearch = searchQuery.trim().toLocaleLowerCase(); + const filteredSummaries = useMemo(() => (normalizedSearch ? summaries.filter((summary) => matchesIncidentSearch(summary, normalizedSearch)) : summaries), [normalizedSearch, summaries]); useFocusEffect( useCallback(() => { @@ -53,11 +64,17 @@ export default function Incidents() { return ( testID="incidents-list" - data={summaries} + data={filteredSummaries} keyExtractor={(item: IncidentCommandSummary) => item.IncidentCommandId} renderItem={({ item }: { item: IncidentCommandSummary }) => handleOpen(item)} />} refreshControl={} - ListEmptyComponent={} + ListEmptyComponent={ + normalizedSearch ? ( + + ) : ( + + ) + } contentContainerStyle={{ paddingBottom: 20 }} /> ); @@ -67,6 +84,18 @@ export default function Incidents() { + + + + + + {searchQuery ? ( + setSearchQuery('')} accessibilityLabel={t('common.clear')} accessibilityRole="button" testID="incidents-search-clear" hitSlop={8}> + + + ) : null} + + {/* Active-only by default; the second chip adds ended incidents (read-only history) */} setIncludeClosed(false)} className={`rounded-full px-4 py-2 ${!includeClosed ? 'bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'}`} testID="incidents-filter-active"> diff --git a/src/app/(app)/index.tsx b/src/app/(app)/index.tsx index ebc8647..e6042a6 100644 --- a/src/app/(app)/index.tsx +++ b/src/app/(app)/index.tsx @@ -254,7 +254,7 @@ function MapContent() { const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal); if (mapDataAndMarkers && mapDataAndMarkers.Data) { - setMapPins(mapDataAndMarkers.Data.MapMakerInfos); + setMapPins(mapDataAndMarkers.Data.MapMakerInfos ?? []); } } catch (error) { // Don't log aborted requests as errors diff --git a/src/app/(app)/maps/_layout.tsx b/src/app/(app)/maps/_layout.tsx new file mode 100644 index 0000000..d34551d --- /dev/null +++ b/src/app/(app)/maps/_layout.tsx @@ -0,0 +1,17 @@ +import { Stack } from 'expo-router'; +import React from 'react'; + +export default function MapsLayout() { + return ( + + + + + + + ); +} diff --git a/src/app/maps/custom/[id].tsx b/src/app/(app)/maps/custom/[id].tsx similarity index 99% rename from src/app/maps/custom/[id].tsx rename to src/app/(app)/maps/custom/[id].tsx index fc6f0ad..0ce228f 100644 --- a/src/app/maps/custom/[id].tsx +++ b/src/app/(app)/maps/custom/[id].tsx @@ -17,7 +17,7 @@ import { VStack } from '@/components/ui/vstack'; import { type CustomMapLayerResultData } from '@/models/v4/mapping/customMapResultData'; import { useMapsStore } from '@/stores/maps/store'; -export default function CustomMapViewer() { +export default function CustomMapViewer(): React.JSX.Element { const { t } = useTranslation(); const { id } = useLocalSearchParams<{ id: string }>(); const currentCustomMap = useMapsStore((state) => state.currentCustomMap); diff --git a/src/app/maps/index.tsx b/src/app/(app)/maps/index.tsx similarity index 99% rename from src/app/maps/index.tsx rename to src/app/(app)/maps/index.tsx index e464021..a67cfd9 100644 --- a/src/app/maps/index.tsx +++ b/src/app/(app)/maps/index.tsx @@ -21,7 +21,7 @@ import { type IndoorMapResultData } from '@/models/v4/mapping/indoorMapResultDat import { type ActiveLayerSummary } from '@/models/v4/mapping/mappingResults'; import { useMapsStore } from '@/stores/maps/store'; -export default function MapsHome() { +export default function MapsHome(): React.JSX.Element { const { t } = useTranslation(); const getCustomMapTypeLabel = (type: number): string => { diff --git a/src/app/maps/indoor/[id].tsx b/src/app/(app)/maps/indoor/[id].tsx similarity index 99% rename from src/app/maps/indoor/[id].tsx rename to src/app/(app)/maps/indoor/[id].tsx index be33824..037ba3b 100644 --- a/src/app/maps/indoor/[id].tsx +++ b/src/app/(app)/maps/indoor/[id].tsx @@ -20,7 +20,7 @@ import { VStack } from '@/components/ui/vstack'; import { type IndoorMapFloorResultData } from '@/models/v4/mapping/indoorMapResultData'; import { useMapsStore } from '@/stores/maps/store'; -export default function IndoorMapViewer() { +export default function IndoorMapViewer(): React.JSX.Element { const { t } = useTranslation(); const { id } = useLocalSearchParams<{ id: string }>(); const currentIndoorMap = useMapsStore((state) => state.currentIndoorMap); diff --git a/src/app/maps/search.tsx b/src/app/(app)/maps/search.tsx similarity index 99% rename from src/app/maps/search.tsx rename to src/app/(app)/maps/search.tsx index d9038d7..dfa7449 100644 --- a/src/app/maps/search.tsx +++ b/src/app/(app)/maps/search.tsx @@ -25,7 +25,7 @@ const SEGMENTS: { labelKey: string; value: SearchFilter }[] = [ { labelKey: 'maps.custom', value: 'custom' }, ]; -export default function MapSearch() { +export default function MapSearch(): React.JSX.Element { const { t } = useTranslation(); const searchResults = useMapsStore((state) => state.searchResults); const isLoading = useMapsStore((state) => state.isLoading); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 3dc1296..1d74d6b 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -190,7 +190,6 @@ function RootLayout() { - ); diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index b884ba6..1d7ef15 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -1,4 +1,4 @@ -import { format } from 'date-fns'; +import { format, isValid } from 'date-fns'; import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { ClockIcon, FileTextIcon, ImageIcon, InfoIcon, LoaderIcon, PaperclipIcon, RouteIcon, TimerIcon, UserIcon, UsersIcon, VideoIcon } from 'lucide-react-native'; import { useColorScheme } from 'nativewind'; @@ -348,7 +348,7 @@ export default function CallDetail() { {t('call_detail.timestamp')} - {format(new Date(call.LoggedOn), 'MMM d, h:mm a')} + {isValid(new Date(call.LoggedOn)) ? format(new Date(call.LoggedOn), 'MMM d, h:mm a') : call.LoggedOn} {t('call_detail.type')} @@ -471,7 +471,7 @@ export default function CallDetail() { {event.Name} - {event.Group} - {new Date(event.Timestamp).toLocaleString()} + {isValid(new Date(event.Timestamp)) ? new Date(event.Timestamp).toLocaleString() : ''} {event.Note} ))} diff --git a/src/app/call/__tests__/[id].security.test.tsx b/src/app/call/__tests__/[id].security.test.tsx index fb91f43..08b1066 100644 --- a/src/app/call/__tests__/[id].security.test.tsx +++ b/src/app/call/__tests__/[id].security.test.tsx @@ -310,6 +310,7 @@ jest.mock('@/components/ui/html-renderer', () => ({ // Mock date-fns jest.mock('date-fns', () => ({ format: (date: any, formatStr: string) => `formatted-${formatStr}`, + isValid: (date: any) => date instanceof Date && !Number.isNaN(date.getTime()), })); // Mock lucide-react-native icons diff --git a/src/app/call/__tests__/[id].test.tsx b/src/app/call/__tests__/[id].test.tsx index abf8d8d..3f76b06 100644 --- a/src/app/call/__tests__/[id].test.tsx +++ b/src/app/call/__tests__/[id].test.tsx @@ -213,6 +213,7 @@ jest.mock('react-native-svg', () => ({ // Mock date-fns jest.mock('date-fns', () => ({ format: jest.fn(() => '2024-01-01 12:00'), + isValid: jest.fn(() => true), })); // Mock HtmlRenderer diff --git a/src/app/command-map/[callId].tsx b/src/app/command-map/[callId].tsx index 3f04cfb..2665a7d 100644 --- a/src/app/command-map/[callId].tsx +++ b/src/app/command-map/[callId].tsx @@ -67,6 +67,10 @@ export default function CommandMapScreen() { // Latest camera framing, captured from map movement so "Save View" pins exactly what's on screen. const cameraStateRef = useRef<{ center: [number, number]; zoom: number } | null>(null); + const cameraRef = useRef(null); // Using any due to imperative handle + // Tracks what the camera was auto-centered on so higher-priority targets + // arriving later (saved view > ICP > first pin) can still recenter. + const autoCenterPriorityRef = useRef<0 | 1 | 2>(0); useEffect(() => { let cancelled = false; @@ -112,8 +116,43 @@ export default function CommandMapScreen() { return { centerCoordinate: [first.Longitude, first.Latitude] as [number, number], zoomLevel: 13 }; } return { centerCoordinate: DEFAULT_CENTER, zoomLevel: 4 }; + // incidentPins intentionally omitted: pins arrive async and are applied via the recenter effect below // eslint-disable-next-line react-hooks/exhaustive-deps - }, [command?.MapCenterLatitude, command?.MapCenterLongitude, command?.MapZoomLevel, namedMap?.CenterLatitude, namedMap?.CenterLongitude, namedMap?.ZoomLevel]); + }, [ + command?.MapCenterLatitude, + command?.MapCenterLongitude, + command?.MapZoomLevel, + command?.CommandPostLatitude, + command?.CommandPostLongitude, + namedMap?.CenterLatitude, + namedMap?.CenterLongitude, + namedMap?.ZoomLevel, + ]); + + // initialCamera is only applied at mount (defaultSettings). Pins and the ICP + // arrive asynchronously after mount, so recenter imperatively once they load. + useEffect(() => { + const hasSavedView = Boolean(namedMap?.CenterLatitude && namedMap?.CenterLongitude) || Boolean(command?.MapCenterLatitude && command?.MapCenterLongitude); + if (hasSavedView) { + return; + } + + const icpLat = parseFloat(command?.CommandPostLatitude ?? ''); + const icpLon = parseFloat(command?.CommandPostLongitude ?? ''); + if (Number.isFinite(icpLat) && Number.isFinite(icpLon)) { + if (autoCenterPriorityRef.current < 2) { + autoCenterPriorityRef.current = 2; + cameraRef.current?.setCamera({ centerCoordinate: [icpLon, icpLat], zoomLevel: 14, animationDuration: 500 }); + } + return; + } + + const first = incidentPins[0]; + if (first && autoCenterPriorityRef.current < 1) { + autoCenterPriorityRef.current = 1; + cameraRef.current?.setCamera({ centerCoordinate: [first.Longitude, first.Latitude], zoomLevel: 13, animationDuration: 500 }); + } + }, [incidentPins, namedMap, command]); const handleCameraChanged = useCallback((state: { properties?: { center?: number[]; zoom?: number } }) => { const center = state?.properties?.center; @@ -233,7 +272,7 @@ export default function CommandMapScreen() { - + (mode === 'none' ? setPendingDeleteId(annotationId) : undefined)} /> {draftShape.features.length > 0 ? ( diff --git a/src/app/maps/_layout.tsx b/src/app/maps/_layout.tsx deleted file mode 100644 index 55afee3..0000000 --- a/src/app/maps/_layout.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Stack } from 'expo-router'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -export default function MapsLayout() { - const { t } = useTranslation(); - - return ( - - - - - - - ); -} diff --git a/src/components/contacts/contact-notes-list.tsx b/src/components/contacts/contact-notes-list.tsx index ed49df1..25eebf4 100644 --- a/src/components/contacts/contact-notes-list.tsx +++ b/src/components/contacts/contact-notes-list.tsx @@ -33,11 +33,8 @@ const ContactNoteCard: React.FC = ({ note }) => { const { colorScheme } = useColorScheme(); const formatDate = (dateString: string) => { - try { - return new Date(dateString).toLocaleDateString(); - } catch { - return dateString; - } + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? dateString : date.toLocaleDateString(); }; // Fallback display for empty or plain text notes @@ -239,9 +236,9 @@ export const ContactNotesList: React.FC = ({ contactId }) // Sort notes by date (newest first) const sortedNotes = [...notes].sort((a, b) => { - const dateA = new Date(a.AddedOnUtc || a.AddedOn); - const dateB = new Date(b.AddedOnUtc || b.AddedOn); - return dateB.getTime() - dateA.getTime(); + const dateA = new Date(a.AddedOnUtc || a.AddedOn).getTime() || 0; + const dateB = new Date(b.AddedOnUtc || b.AddedOn).getTime() || 0; + return dateB - dateA; }); return ( diff --git a/src/features/livekit-call/store/useLiveKitCallStore.ts b/src/features/livekit-call/store/useLiveKitCallStore.ts index 0f3a228..f738165 100644 --- a/src/features/livekit-call/store/useLiveKitCallStore.ts +++ b/src/features/livekit-call/store/useLiveKitCallStore.ts @@ -226,8 +226,10 @@ export const useLiveKitCallStore = create((set, get) => ({ autoSubscribe: true, // Subscribe to all tracks by default }; - //await newRoom.connect(LIVEKIT_URL, token, connectOptions); - // Connection success is handled by the ConnectionStateChanged event listener + void connectOptions; + // Token fetching is not implemented: no LiveKit token endpoint exists in the API layer. + // Fail fast instead of leaving isConnecting=true forever. + throw new Error('LiveKit room connection is not available.'); } catch (err: any) { logger.error({ message: 'Failed to connect to LiveKit room', diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts index 2a8ac41..0401011 100644 --- a/src/hooks/use-map-signalr-updates.ts +++ b/src/hooks/use-map-signalr-updates.ts @@ -62,15 +62,16 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData } if (mapDataAndMarkers && mapDataAndMarkers.Data) { + const markers = mapDataAndMarkers.Data.MapMakerInfos ?? []; logger.info({ message: 'Updating map markers from SignalR update', context: { - markerCount: mapDataAndMarkers.Data.MapMakerInfos.length, + markerCount: markers.length, timestamp: timestampToProcess, }, }); - onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos); + onMarkersUpdate(markers); } // Update the last processed timestamp after successful API call diff --git a/src/lib/__tests__/maps-route.test.ts b/src/lib/__tests__/maps-route.test.ts new file mode 100644 index 0000000..af33e83 --- /dev/null +++ b/src/lib/__tests__/maps-route.test.ts @@ -0,0 +1,25 @@ +import { getMapsHeaderState } from '@/lib/maps-route'; + +describe('getMapsHeaderState', () => { + it('shows the drawer menu on the Maps landing page', () => { + expect(getMapsHeaderState('/maps')).toEqual({ + showMenu: true, + titleKey: 'maps.title', + }); + expect(getMapsHeaderState('/maps/')).toEqual({ + showMenu: true, + titleKey: 'maps.title', + }); + }); + + it.each([ + ['/maps/search', 'maps.search_maps'], + ['/maps/custom/custom-map-id', 'maps.custom_maps'], + ['/maps/indoor/indoor-map-id', 'maps.indoor_maps'], + ] as const)('shows a back button and the correct title for %s', (pathname, titleKey) => { + expect(getMapsHeaderState(pathname)).toEqual({ + showMenu: false, + titleKey, + }); + }); +}); diff --git a/src/lib/auth/api.tsx b/src/lib/auth/api.tsx index bc4b930..fab35bb 100644 --- a/src/lib/auth/api.tsx +++ b/src/lib/auth/api.tsx @@ -9,6 +9,7 @@ import type { AuthResponse, LoginCredentials, LoginResponse, SsoLoginCredentials const authApi = axios.create({ baseURL: getBaseApiUrl(), + timeout: 15000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -88,6 +89,23 @@ export const refreshTokenRequest = async (refreshToken: string): Promise | null = null; + +export const refreshTokenSingleFlight = (refreshToken: string): Promise => { + if (!inFlightRefresh) { + inFlightRefresh = refreshTokenRequest(refreshToken).finally(() => { + inFlightRefresh = null; + }); + } + return inFlightRefresh; +}; + /** * Exchange an external IdP token (OIDC id_token or SAML SAMLResponse) for * Resgrid access/refresh tokens via POST /api/v4/connect/external-token. diff --git a/src/lib/cache/cache-manager.ts b/src/lib/cache/cache-manager.ts index 69cb746..b37ec7c 100644 --- a/src/lib/cache/cache-manager.ts +++ b/src/lib/cache/cache-manager.ts @@ -46,7 +46,13 @@ export class CacheManager { return null; } - const cacheItem: CacheItem = JSON.parse(cached); + let cacheItem: CacheItem; + try { + cacheItem = JSON.parse(cached); + } catch { + storage.delete(key); + return null; + } if (this.isExpired(cacheItem.timestamp, cacheItem.expiresIn)) { // Keep the entry on disk — getStale() serves it as an offline fallback. @@ -68,8 +74,13 @@ export class CacheManager { return null; } - const cacheItem: CacheItem = JSON.parse(cached); - return cacheItem.data; + try { + const cacheItem: CacheItem = JSON.parse(cached); + return cacheItem.data; + } catch { + storage.delete(key); + return null; + } } remove(endpoint: string, params?: Record): void { diff --git a/src/lib/maps-route.ts b/src/lib/maps-route.ts new file mode 100644 index 0000000..6f6758a --- /dev/null +++ b/src/lib/maps-route.ts @@ -0,0 +1,43 @@ +export type MapsHeaderTitleKey = 'maps.custom_maps' | 'maps.indoor_maps' | 'maps.search_maps' | 'maps.title'; + +interface MapsHeaderState { + showMenu: boolean; + titleKey: MapsHeaderTitleKey; +} + +export const getMapsHeaderState = (pathname: string): MapsHeaderState => { + const normalizedPathname = pathname.replace(/\/+$/, '') || '/'; + + if (normalizedPathname === '/maps') { + return { + showMenu: true, + titleKey: 'maps.title', + }; + } + + if (normalizedPathname.startsWith('/maps/search')) { + return { + showMenu: false, + titleKey: 'maps.search_maps', + }; + } + + if (normalizedPathname.startsWith('/maps/custom/')) { + return { + showMenu: false, + titleKey: 'maps.custom_maps', + }; + } + + if (normalizedPathname.startsWith('/maps/indoor/')) { + return { + showMenu: false, + titleKey: 'maps.indoor_maps', + }; + } + + return { + showMenu: false, + titleKey: 'maps.title', + }; +}; diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx index 30d8051..b56e3ab 100644 --- a/src/lib/storage/app.tsx +++ b/src/lib/storage/app.tsx @@ -22,10 +22,7 @@ export const setActiveCallId = (value: string) => setItem(ACTIVE_CALL_ID export const getActiveCallId = () => { const activeCallId = getItem(ACTIVE_CALL_ID); - if (!activeCallId) { - return activeCallId; - } - return ''; + return activeCallId ?? null; }; export const removeDeviceUuid = () => removeItem(DEVICE_UUID); diff --git a/src/lib/storage/index.tsx b/src/lib/storage/index.tsx index 8f26a9e..ce9eb21 100644 --- a/src/lib/storage/index.tsx +++ b/src/lib/storage/index.tsx @@ -17,7 +17,15 @@ const IS_FIRST_TIME = 'IS_FIRST_TIME'; export function getItem(key: string): T | null { const value = storage.getString(key); - return value ? JSON.parse(value) : null; + if (!value) { + return null; + } + try { + return JSON.parse(value) as T; + } catch { + storage.delete(key); + return null; + } } export async function setItem(key: string, value: T) { diff --git a/src/services/__tests__/location.test.ts b/src/services/__tests__/location.test.ts index eaba99f..1e8a434 100644 --- a/src/services/__tests__/location.test.ts +++ b/src/services/__tests__/location.test.ts @@ -484,6 +484,27 @@ describe('LocationService', () => { expect(mockLocationStoreState.setLocation).toHaveBeenCalledWith(mockLocationObject); expect(mockSetUnitLocation).not.toHaveBeenCalled(); }); + + it('should catch and log rejected background location API sends', async () => { + await locationService.startBackgroundUpdates(); + + const locationCallback = mockLocation.watchPositionAsync.mock.calls[0][1]; + const catchSpy = jest.spyOn(Promise.prototype, 'catch'); + + locationCallback(mockLocationObject); + + const rejectionHandler = catchSpy.mock.calls[0]?.[0]; + catchSpy.mockRestore(); + + const networkError = new Error('Location API request failed'); + expect(rejectionHandler).toEqual(expect.any(Function)); + rejectionHandler?.(networkError); + + expect(mockLogger.error).toHaveBeenCalledWith({ + message: 'Failed to send background location update to API', + context: { error: networkError }, + }); + }); }); describe('API Integration', () => { diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index 7bdb98f..c6bdaa3 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -170,7 +170,8 @@ describe('PushNotificationService (expo-notifications transport)', () => { expect(mockAddNotificationReceivedListener).toHaveBeenCalledTimes(1); expect(mockAddNotificationResponseReceivedListener).toHaveBeenCalledTimes(1); expect(mockOnForegroundEvent).toHaveBeenCalledTimes(1); - expect(mockOnBackgroundEvent).toHaveBeenCalledTimes(1); + // Background handler is registered at module scope (see test below), not in initialize() + expect(mockOnBackgroundEvent).not.toHaveBeenCalled(); // iOS platform mock: no Android channels, but categories set expect(mockSetNotificationCategories).toHaveBeenCalledTimes(1); expect(mockCreateChannel).not.toHaveBeenCalled(); @@ -342,6 +343,17 @@ describe('PushNotificationService (expo-notifications transport)', () => { }); }); + describe('module scope', () => { + it('registers the notifee background handler at module load (headless/killed state)', () => { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('../push-notification'); + }); + + expect(mockOnBackgroundEvent).toHaveBeenCalledTimes(1); + }); + }); + describe('cleanup', () => { it('removes all listeners', async () => { await pushNotificationService.initialize(); diff --git a/src/services/__tests__/signalr.service.enhanced.test.ts b/src/services/__tests__/signalr.service.enhanced.test.ts index 9bd63fa..ebcfc77 100644 --- a/src/services/__tests__/signalr.service.enhanced.test.ts +++ b/src/services/__tests__/signalr.service.enhanced.test.ts @@ -323,14 +323,9 @@ describe('SignalRService - Enhanced Features', () => { // Advance timers to trigger the scheduled reconnect jest.advanceTimersByTime(5000); - - // Assert that no reconnection attempt occurs - // The reconnect logic should not be called because the hub was explicitly disconnected - expect(mockLogger.debug).toHaveBeenCalledWith({ - message: `Hub ${mockConfig.name} config was removed, skipping reconnection attempt`, - }); - - // Ensure no actual reconnection attempt was made + + // The pending reconnect timer is cancelled by disconnectFromHub, so the + // timer callback never runs and no reconnection is attempted expect(mockLogger.info).not.toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('attempting to reconnect to hub'), diff --git a/src/services/bluetooth-audio.service.ts b/src/services/bluetooth-audio.service.ts index aa011d1..0c25e74 100644 --- a/src/services/bluetooth-audio.service.ts +++ b/src/services/bluetooth-audio.service.ts @@ -185,7 +185,8 @@ class BluetoothAudioService { }); // If direct connection fails, start scanning to find the device - this.startScanning(5000); // 5 second scan + // Await so scan failures (BT off, permission denied) stay inside this try/catch + await this.startScanning(5000); // 5 second scan } } else { logger.info({ diff --git a/src/services/callkeep.service.ts b/src/services/callkeep.service.ts index 7f70445..d6d9278 100644 --- a/src/services/callkeep.service.ts +++ b/src/services/callkeep.service.ts @@ -1,12 +1,9 @@ -import { Platform } from 'react-native'; +// TypeScript-facing barrel for './callkeep.service' imports. Metro and jest +// resolve the platform-specific files (callkeep.service.ios.ts / +// callkeep.service.android.ts / callkeep.service.web.ts) BEFORE this file, so +// at runtime this module is shadowed and never bundled. It exists so tsc can +// type-check those imports. It re-exports the web implementation because it +// has no native dependencies. -import { callKeepService as androidCallKeepService } from './callkeep.service.android'; -import { callKeepService as iosCallKeepService } from './callkeep.service.ios'; -import { callKeepService as webCallKeepService } from './callkeep.service.web'; - -// Export the appropriate platform-specific implementation -export const callKeepService = Platform.OS === 'ios' ? iosCallKeepService : Platform.OS === 'web' ? webCallKeepService : androidCallKeepService; - -// Re-export types from iOS (they're the same in both) -export type { CallKeepConfig } from './callkeep.service.ios'; -export { CallKeepService } from './callkeep.service.ios'; +export type { CallKeepConfig } from './callkeep.service.web'; +export { CallKeepService, callKeepService } from './callkeep.service.web'; diff --git a/src/services/location.ts b/src/services/location.ts index d40e187..9b6e40f 100644 --- a/src/services/location.ts +++ b/src/services/location.ts @@ -79,10 +79,18 @@ class LocationService { context: { nextAppState, backgroundEnabled: this.isBackgroundGeolocationEnabled }, }); - if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) { - await this.startBackgroundUpdates(); - } else if (nextAppState === 'active') { - await this.stopBackgroundUpdates(); + // AppState event handlers don't handle promise rejections — catch everything + try { + if (nextAppState === 'background' && this.isBackgroundGeolocationEnabled) { + await this.startBackgroundUpdates(); + } else if (nextAppState === 'active') { + await this.stopBackgroundUpdates(); + } + } catch (error) { + logger.error({ + message: 'Location service failed to handle app state change', + context: { error, nextAppState }, + }); } }; @@ -133,7 +141,12 @@ class LocationService { timestamp: pos.timestamp, }; useLocationStore.getState().setLocation(loc); - sendLocationToAPI(loc); + void sendLocationToAPI(loc).catch((error) => { + logger.error({ + message: 'Failed to send web location update to API', + context: { error }, + }); + }); }, (err) => { logger.warn({ message: 'Web geolocation error', context: { code: err.code, msg: err.message } }); @@ -208,7 +221,12 @@ class LocationService { }, }); useLocationStore.getState().setLocation(location); - sendLocationToAPI(location); // Send to API for foreground updates + void sendLocationToAPI(location).catch((error) => { + logger.error({ + message: 'Failed to send foreground location update to API', + context: { error }, + }); + }); } ); } else { @@ -243,31 +261,55 @@ class LocationService { return; } + // Re-check background permission: starting updates while backgrounded + // without background permission throws + const { status: backgroundStatus } = await Location.getBackgroundPermissionsAsync(); + if (backgroundStatus !== 'granted') { + logger.warn({ + message: 'Skipping background location updates: background permission not granted', + context: { backgroundStatus }, + }); + return; + } + logger.info({ message: 'Starting background location updates', }); - this.backgroundSubscription = await Location.watchPositionAsync( - { - accuracy: Location.Accuracy.Balanced, - timeInterval: 60000, - distanceInterval: 20, - }, - (location) => { - logger.info({ - message: 'Background location update received', - context: { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - heading: location.coords.heading, - }, - }); - useLocationStore.getState().setLocation(location); - sendLocationToAPI(location); // Send to API for background updates - } - ); + try { + this.backgroundSubscription = await Location.watchPositionAsync( + { + accuracy: Location.Accuracy.Balanced, + timeInterval: 60000, + distanceInterval: 20, + }, + (location) => { + logger.info({ + message: 'Background location update received', + context: { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + heading: location.coords.heading, + }, + }); + useLocationStore.getState().setLocation(location); + void sendLocationToAPI(location).catch((error) => { + logger.error({ + message: 'Failed to send background location update to API', + context: { error }, + }); + }); + } + ); - useLocationStore.getState().setBackgroundEnabled(true); + useLocationStore.getState().setBackgroundEnabled(true); + } catch (error) { + this.backgroundSubscription = null; + logger.error({ + message: 'Failed to start background location updates', + context: { error }, + }); + } } async stopBackgroundUpdates(): Promise { diff --git a/src/services/push-notification.ts b/src/services/push-notification.ts index 84d246b..e7b4224 100644 --- a/src/services/push-notification.ts +++ b/src/services/push-notification.ts @@ -234,42 +234,10 @@ class PushNotificationService { this.showModalForData(detail.notification.data, detail.notification.title, detail.notification.body); } }); - - notifee.onBackgroundEvent(async ({ type, detail }) => { - logger.info({ - message: 'Notifee background event', - context: { type, detail: { id: detail.notification?.id, data: detail.notification?.data } }, - }); - - if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') { - await this.handleCheckInAction(); - } - - if (type === EventType.PRESS && detail.notification) { - this.showModalForData(detail.notification.data, detail.notification.title, detail.notification.body); - } - }); } - private async handleCheckInAction(): Promise { - logger.info({ message: 'Check-in action pressed from notification' }); - const activeCall = useCoreStore.getState().activeCall; - if (!activeCall) { - return; - } - - const callId = parseInt(activeCall.CallId, 10); - if (Number.isNaN(callId)) { - logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } }); - return; - } - - await useCheckInTimerStore.getState().performCheckIn({ - CallId: callId, - CheckInType: CHECK_IN_TYPE_PERSONNEL, - Latitude: useLocationStore.getState().latitude?.toString(), - Longitude: useLocationStore.getState().longitude?.toString(), - }); + public async handleCheckInAction(): Promise { + await handleCheckInActionFromEvent(); } async initialize(): Promise { @@ -432,6 +400,52 @@ class PushNotificationService { } } +/** + * Shared check-in action handler for foreground and background notifee events. + * Never throws: a rejected promise inside a notifee event handler is an + * unhandled rejection that can kill the headless background task. + */ +const handleCheckInActionFromEvent = async (): Promise => { + try { + logger.info({ message: 'Check-in action pressed from notification' }); + const activeCall = useCoreStore.getState().activeCall; + if (!activeCall) { + return; + } + + const callId = parseInt(activeCall.CallId, 10); + if (Number.isNaN(callId)) { + logger.error({ message: 'Check-in action aborted: invalid CallId', context: { CallId: activeCall.CallId } }); + return; + } + + // performCheckIn queues the event offline when the network request fails + await useCheckInTimerStore.getState().performCheckIn({ + CallId: callId, + CheckInType: CHECK_IN_TYPE_PERSONNEL, + Latitude: useLocationStore.getState().latitude?.toString(), + Longitude: useLocationStore.getState().longitude?.toString(), + }); + } catch (error) { + logger.error({ + message: 'Check-in action failed', + context: { error }, + }); + } +}; + +// Notifee requires the background event handler to be registered at module +// scope: when the app is killed, only the headless JS task runs and +// initialize() is never called, so a registration inside initialize() would +// never fire for action presses from the killed state. +if (Platform.OS !== 'web') { + notifee.onBackgroundEvent(async ({ type, detail }) => { + if (type === EventType.ACTION_PRESS && detail.pressAction?.id === 'check-in') { + await handleCheckInActionFromEvent(); + } + }); +} + export const pushNotificationService = PushNotificationService.getInstance(); // React hook for component usage diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index 0864528..1b4f3cc 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -36,6 +36,7 @@ class SignalRService { private connectionLocks: Map> = new Map(); private reconnectingHubs: Set = new Set(); private hubStates: Map = new Map(); + private reconnectTimers: Map> = new Map(); private readonly MAX_RECONNECT_ATTEMPTS = 5; private readonly RECONNECT_INTERVAL = 5000; // 5 seconds @@ -194,7 +195,8 @@ class SignalRService { isGeolocationHub ? {} : { - accessTokenFactory: () => token, + // Read the token lazily so automatic reconnects use the current access token + accessTokenFactory: () => useAuthStore.getState().accessToken ?? token, } ) .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) @@ -325,7 +327,8 @@ class SignalRService { const connection = new HubConnectionBuilder() .withUrl(config.url, { - accessTokenFactory: () => token, + // Read the token lazily so automatic reconnects use the current access token + accessTokenFactory: () => useAuthStore.getState().accessToken ?? token, }) .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) .configureLogging(signalRLogLevel) @@ -401,7 +404,15 @@ class SignalRService { message: `Scheduling reconnection attempt ${currentAttempts}/${this.MAX_RECONNECT_ATTEMPTS} for hub: ${hubName}`, }); - setTimeout(async () => { + // Cancel any pending reconnect timer before scheduling a new one + const pendingTimer = this.reconnectTimers.get(hubName); + if (pendingTimer) { + clearTimeout(pendingTimer); + this.reconnectTimers.delete(hubName); + } + + const reconnectTimer = setTimeout(async () => { + this.reconnectTimers.delete(hubName); try { // Check if the hub config was removed (e.g., by explicit disconnect) const currentHubConfig = this.hubConfigs.get(hubName); @@ -481,6 +492,7 @@ class SignalRService { // This prevents rapid retry loops that could overwhelm the server } }, this.RECONNECT_INTERVAL); + this.reconnectTimers.set(hubName, reconnectTimer); } else { logger.error({ message: `No stored config found for hub: ${hubName}, cannot attempt reconnection`, @@ -505,6 +517,13 @@ class SignalRService { } public async disconnectFromHub(hubName: string): Promise { + // Cancel any pending reconnection attempt so it doesn't fire after teardown + const pendingTimer = this.reconnectTimers.get(hubName); + if (pendingTimer) { + clearTimeout(pendingTimer); + this.reconnectTimers.delete(hubName); + } + // Wait for any ongoing connection attempt to complete const existingLock = this.connectionLocks.get(hubName); if (existingLock) { @@ -620,7 +639,16 @@ class SignalRService { } private emit(event: string, data: unknown): void { - this.eventListeners.get(event)?.forEach((callback) => callback(data)); + this.eventListeners.get(event)?.forEach((callback) => { + try { + callback(data); + } catch (error) { + logger.error({ + message: `Error in SignalR event listener for event: ${event}`, + context: { error }, + }); + } + }); } } diff --git a/src/stores/app/livekit-store.ts b/src/stores/app/livekit-store.ts index 36c5a9d..459102c 100644 --- a/src/stores/app/livekit-store.ts +++ b/src/stores/app/livekit-store.ts @@ -565,6 +565,29 @@ export const useLiveKitStore = create((set, get) => ({ set({ isTalking }); }); + room.on(RoomEvent.Reconnecting, () => { + logger.warn({ + message: 'LiveKit room connection lost, attempting to reconnect', + context: { roomName: roomInfo.Name }, + }); + }); + + room.on(RoomEvent.Disconnected, () => { + logger.warn({ + message: 'LiveKit room disconnected (server kick or unrecoverable network loss)', + context: { roomName: roomInfo.Name }, + }); + // Only clean up if this room is still the active one (avoids racing a manual disconnect) + if (get().currentRoom === room) { + Promise.resolve(get().disconnectFromRoom()).catch((cleanupError: unknown) => { + logger.error({ + message: 'Failed to clean up after LiveKit room disconnect', + context: { error: cleanupError }, + }); + }); + } + }); + // Connect to the room logger.info({ message: 'Connecting to LiveKit room', @@ -762,8 +785,23 @@ export const useLiveKitStore = create((set, get) => ({ disconnectFromRoom: async () => { const { currentRoom } = get(); if (currentRoom) { - await currentRoom.disconnect(); - await audioService.playDisconnectedFromAudioRoomSound(); + // If disconnect throws, CallKeep/foreground-service/state cleanup must still run + try { + await currentRoom.disconnect(); + } catch (disconnectError) { + logger.warn({ + message: 'Failed to disconnect LiveKit room cleanly, continuing cleanup', + context: { error: disconnectError }, + }); + } + try { + await audioService.playDisconnectedFromAudioRoomSound(); + } catch (soundError) { + logger.warn({ + message: 'Failed to play disconnect sound, continuing cleanup', + context: { error: soundError }, + }); + } // Stop the native audio session that was started during connectToRoom if (Platform.OS !== 'web') { diff --git a/src/stores/auth/store.tsx b/src/stores/auth/store.tsx index 5b2a670..743dbb9 100644 --- a/src/stores/auth/store.tsx +++ b/src/stores/auth/store.tsx @@ -5,7 +5,7 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { logger } from '@/lib/logging'; -import { loginRequest, refreshTokenRequest, ssoExternalTokenRequest } from '../../lib/auth/api'; +import { loginRequest, refreshTokenSingleFlight, ssoExternalTokenRequest } from '../../lib/auth/api'; import type { AuthResponse, AuthState, LoginCredentials, SsoLoginCredentials } from '../../lib/auth/types'; import { type ProfileModel } from '../../lib/auth/types'; import { getAuth } from '../../lib/auth/utils'; @@ -32,8 +32,9 @@ const useAuthStore = create()( const payload = sanitizeJson(base64.decode(response.authResponse!.id_token!.split('.')[1])); setItem('authResponse', response.authResponse!); + const expiresInSeconds = response.authResponse?.expires_in ?? 3600; const now = new Date(); - const expiresOn = new Date(now.getTime() + response.authResponse?.expires_in! * 1000).getTime().toString(); + const expiresOn = new Date(now.getTime() + expiresInSeconds * 1000).getTime().toString(); const profileData = JSON.parse(payload) as ProfileModel; @@ -63,10 +64,10 @@ const useAuthStore = create()( // Schedule proactive token refresh before expiry // expires_in is in seconds, so convert to milliseconds and refresh 1 minute before expiry - const refreshDelayMs = Math.max((response.authResponse!.expires_in - 60) * 1000, 60000); + const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000); logger.info({ message: 'Login successful, scheduling token refresh', - context: { refreshDelayMs, expiresInSeconds: response.authResponse!.expires_in }, + context: { refreshDelayMs, expiresInSeconds }, }); // Clear any existing refresh timer before scheduling a new one const existingTimeoutId = get().refreshTimeoutId; @@ -98,7 +99,8 @@ const useAuthStore = create()( const payload = sanitizeJson(base64.decode(response.authResponse.id_token!.split('.')[1])); setItem('authResponse', response.authResponse); - const expiresOn = new Date(Date.now() + response.authResponse.expires_in * 1000).getTime().toString(); + const expiresInSeconds = response.authResponse.expires_in ?? 3600; + const expiresOn = new Date(Date.now() + expiresInSeconds * 1000).getTime().toString(); const profileData = JSON.parse(payload) as ProfileModel; @@ -114,7 +116,7 @@ const useAuthStore = create()( Sentry.setUser({ id: profileData.sub, username: profileData.name }); - const refreshDelayMs = Math.max((response.authResponse.expires_in - 60) * 1000, 60000); + const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000); logger.info({ message: 'SSO login successful, scheduling token refresh', context: { refreshDelayMs, provider: credentials.provider }, @@ -166,7 +168,7 @@ const useAuthStore = create()( return; } - const response = await refreshTokenRequest(refreshToken); + const response = await refreshTokenSingleFlight(refreshToken); // Update the stored auth response for hydration setItem('authResponse', response); @@ -180,10 +182,11 @@ const useAuthStore = create()( // Set up next token refresh - refresh 1 minute before expiry // expires_in is in seconds, so convert to milliseconds - const refreshDelayMs = Math.max((response.expires_in - 60) * 1000, 60000); // At least 1 minute + const expiresInSeconds = response.expires_in ?? 3600; + const refreshDelayMs = Math.max((expiresInSeconds - 60) * 1000, 60000); // At least 1 minute logger.info({ message: 'Token refreshed successfully, scheduling next refresh', - context: { refreshDelayMs, expiresInSeconds: response.expires_in }, + context: { refreshDelayMs, expiresInSeconds }, }); // Clear any existing refresh timer before scheduling a new one const existingTimeoutId = get().refreshTimeoutId; diff --git a/src/stores/dispatch/store.ts b/src/stores/dispatch/store.ts index 3de4005..aa6c8d2 100644 --- a/src/stores/dispatch/store.ts +++ b/src/stores/dispatch/store.ts @@ -76,7 +76,7 @@ export const useDispatchStore = create((set, get) => ({ const categorizedUnits: RecipientsResultData[] = []; // Categorize recipients based on Type field - recipients.Data.forEach((recipient) => { + (recipients.Data ?? []).forEach((recipient) => { if (recipient.Type === 'Personnel') { categorizedUsers.push(recipient); } else if (recipient.Type === 'Groups') { diff --git a/src/stores/units/store.ts b/src/stores/units/store.ts index 1b802cb..fd117f4 100644 --- a/src/stores/units/store.ts +++ b/src/stores/units/store.ts @@ -27,7 +27,7 @@ export const useUnitsStore = create((set) => ({ set({ isLoading: true, error: null }); try { const [unitsResponse, unitStatusesResponse, currentStatusesResponse] = await Promise.all([getUnits(), getAllUnitStatuses(), getUnitCurrentStatuses()]); - set({ units: unitsResponse.Data, unitStatuses: unitStatusesResponse.Data, unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false }); + set({ units: unitsResponse.Data ?? [], unitStatuses: unitStatusesResponse.Data ?? [], unitCurrentStatuses: currentStatusesResponse.Data ?? [], isLoading: false }); } catch (error) { set({ error: 'Failed to fetch units', isLoading: false }); } diff --git a/src/translations/ar.json b/src/translations/ar.json index 628f974..14c70ca 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -36,6 +36,7 @@ "applied": "مطبق", "audio": "صوت", "audioActive": "الصوت نشط", + "audio_capable": "يدعم الصوت", "audio_device": "سماعة BT", "audio_output": "مخرج الصوت", "availableDevices": "الأجهزة المتاحة", @@ -47,6 +48,8 @@ "connect": "اتصال", "connected": "متصل", "connectionError": "خطأ في الاتصال", + "connection_error_message": "تعذر الاتصال بالجهاز", + "connection_error_title": "فشل الاتصال", "current_selection": "الاختيار الحالي", "device_disconnected": "تم فصل الجهاز", "disconnect": "قطع الاتصال", @@ -243,8 +246,12 @@ "edit_call_description": "تحديث معلومات المكالمة", "everyone": "الجميع", "files": { + "error": "خطأ في جلب الملفات", + "file_name": "اسم الملف", "no_files": "لا توجد ملفات متاحة", "no_files_description": "لم تتم إضافة أي ملفات إلى هذه المكالمة بعد", + "open_error": "خطأ في فتح الملف", + "share_error": "خطأ في مشاركة الملف", "title": "ملفات المكالمة" }, "geocoding_error": "فشل البحث عن العنوان، يرجى المحاولة مرة أخرى", @@ -696,6 +703,7 @@ "add": "إضافة", "back": "رجوع", "cancel": "إلغاء", + "clear": "مسح", "close": "إغلاق", "confirm": "تأكيد", "confirm_location": "تأكيد الموقع", @@ -722,6 +730,7 @@ "optional": "اختياري", "permission_denied": "تم رفض الإذن", "previous": "السابق", + "refresh": "تحديث", "remove": "إزالة", "retry": "إعادة المحاولة", "route": "مسار", @@ -846,6 +855,7 @@ "no_lanes": "لا يوجد هيكل قيادة بعد", "no_needs": "لا توجد احتياجات", "no_objectives": "لا توجد أهداف بعد", + "no_search_results_description": "لا توجد حوادث مطابقة لبحثك.", "objectives": "الأهداف", "ongoing": "جارٍ", "par_critical": "حرج", @@ -853,6 +863,7 @@ "personnel": "الأفراد", "read_only_banner": "انتهى هذا الحادث — المعلومات للقراءة فقط.", "resources": "الموارد", + "search": "البحث في الحوادث...", "title": "الحوادث", "unassigned": "غير معيّن", "units": "الوحدات", diff --git a/src/translations/de.json b/src/translations/de.json index 1a5b808..aaa612b 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -36,6 +36,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 +48,8 @@ "connect": "Verbinden", "connected": "Verbunden", "connectionError": "Verbindungsfehler", + "connection_error_message": "Verbindung zum Gerät fehlgeschlagen", + "connection_error_title": "Verbindung fehlgeschlagen", "current_selection": "Aktuelle Auswahl", "device_disconnected": "Gerät getrennt", "disconnect": "Trennen", @@ -243,8 +246,12 @@ "edit_call_description": "Anrufinformationen aktualisieren", "everyone": "Alle", "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", "title": "Anrufdateien" }, "geocoding_error": "Adresssuche fehlgeschlagen, bitte erneut versuchen", @@ -696,6 +703,7 @@ "add": "Hinzufügen", "back": "Zurück", "cancel": "Abbrechen", + "clear": "Löschen", "close": "Schließen", "confirm": "Bestätigen", "confirm_location": "Standort bestätigen", @@ -722,6 +730,7 @@ "optional": "optional", "permission_denied": "Berechtigung verweigert", "previous": "Zurück", + "refresh": "Aktualisieren", "remove": "Entfernen", "retry": "Erneut versuchen", "route": "Route", @@ -846,6 +855,7 @@ "no_lanes": "Noch keine Führungsstruktur", "no_needs": "Keine Bedarfe", "no_objectives": "Noch keine Ziele", + "no_search_results_description": "Keine Einsätze entsprechen Ihrer Suche.", "objectives": "Ziele", "ongoing": "Laufend", "par_critical": "Kritisch", @@ -853,6 +863,7 @@ "personnel": "Personal", "read_only_banner": "Dieser Einsatz ist beendet — die Informationen sind schreibgeschützt.", "resources": "Ressourcen", + "search": "Einsätze suchen...", "title": "Einsätze", "unassigned": "Nicht zugewiesen", "units": "Einheiten", diff --git a/src/translations/en.json b/src/translations/en.json index 9f7de2a..abc8024 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -36,6 +36,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 +48,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", @@ -243,8 +246,12 @@ "edit_call_description": "Update call information", "everyone": "Everyone", "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", "title": "Call Files" }, "geocoding_error": "Failed to search for address, please try again", @@ -696,6 +703,7 @@ "add": "Add", "back": "Back", "cancel": "Cancel", + "clear": "Clear", "close": "Close", "confirm": "Confirm", "confirm_location": "Confirm Location", @@ -722,6 +730,7 @@ "optional": "optional", "permission_denied": "Permission denied", "previous": "Previous", + "refresh": "Refresh", "remove": "Remove", "retry": "Retry", "route": "Route", @@ -846,6 +855,7 @@ "no_lanes": "No command structure yet", "no_needs": "No needs", "no_objectives": "No objectives yet", + "no_search_results_description": "No incidents match your search.", "objectives": "Objectives", "ongoing": "Ongoing", "par_critical": "Critical", @@ -853,6 +863,7 @@ "personnel": "Personnel", "read_only_banner": "This incident has ended — information is read-only.", "resources": "Resources", + "search": "Search incidents...", "title": "Incidents", "unassigned": "Unassigned", "units": "Units", diff --git a/src/translations/es.json b/src/translations/es.json index 67f6c9e..4075404 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -36,6 +36,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 +48,8 @@ "connect": "Conectar", "connected": "Conectado", "connectionError": "Error de Conexión", + "connection_error_message": "No se pudo conectar al dispositivo", + "connection_error_title": "Error de conexión", "current_selection": "Selección Actual", "device_disconnected": "Dispositivo desconectado", "disconnect": "Desconectar", @@ -243,8 +246,12 @@ "edit_call_description": "Actualizar información de la llamada", "everyone": "Todos", "files": { + "error": "Error al obtener los archivos", + "file_name": "Nombre de 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", "title": "Archivos de llamada" }, "geocoding_error": "Error al buscar la dirección, por favor inténtelo de nuevo", @@ -696,6 +703,7 @@ "add": "Añadir", "back": "Atrás", "cancel": "Cancelar", + "clear": "Limpiar", "close": "Cerrar", "confirm": "Confirmar", "confirm_location": "Confirmar ubicación", @@ -722,6 +730,7 @@ "optional": "opcional", "permission_denied": "Permiso denegado", "previous": "Anterior", + "refresh": "Actualizar", "remove": "Eliminar", "retry": "Reintentar", "route": "Ruta", @@ -846,6 +855,7 @@ "no_lanes": "Aún no hay estructura de comando", "no_needs": "Sin necesidades", "no_objectives": "Aún no hay objetivos", + "no_search_results_description": "Ningún incidente coincide con tu búsqueda.", "objectives": "Objetivos", "ongoing": "En curso", "par_critical": "Crítico", @@ -853,6 +863,7 @@ "personnel": "Personal", "read_only_banner": "Este incidente ha finalizado; la información es de solo lectura.", "resources": "Recursos", + "search": "Buscar incidentes...", "title": "Incidentes", "unassigned": "Sin asignar", "units": "Unidades", diff --git a/src/translations/fr.json b/src/translations/fr.json index 9b2854e..30db513 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -36,6 +36,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 +48,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", @@ -243,8 +246,12 @@ "edit_call_description": "Mettre à jour les informations de l'appel", "everyone": "Tout le monde", "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", "title": "Fichiers de l'appel" }, "geocoding_error": "Échec de la recherche d'adresse, veuillez réessayer", @@ -696,6 +703,7 @@ "add": "Ajouter", "back": "Retour", "cancel": "Annuler", + "clear": "Effacer", "close": "Fermer", "confirm": "Confirmer", "confirm_location": "Confirmer la localisation", @@ -722,6 +730,7 @@ "optional": "optionnel", "permission_denied": "Permission refusée", "previous": "Précédent", + "refresh": "Actualiser", "remove": "Supprimer", "retry": "Réessayer", "route": "Itinéraire", @@ -846,6 +855,7 @@ "no_lanes": "Pas encore de structure de commandement", "no_needs": "Aucun besoin", "no_objectives": "Pas encore d'objectifs", + "no_search_results_description": "Aucun incident ne correspond à votre recherche.", "objectives": "Objectifs", "ongoing": "En cours", "par_critical": "Critique", @@ -853,6 +863,7 @@ "personnel": "Personnel", "read_only_banner": "Cet incident est terminé — les informations sont en lecture seule.", "resources": "Ressources", + "search": "Rechercher des incidents...", "title": "Incidents", "unassigned": "Non assigné", "units": "Unités", diff --git a/src/translations/it.json b/src/translations/it.json index b1fc910..da60d5a 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -36,6 +36,7 @@ "applied": "Applicato", "audio": "Audio", "audioActive": "Audio attivo", + "audio_capable": "Compatibile audio", "audio_device": "Auricolare BT", "audio_output": "Uscita audio", "availableDevices": "Dispositivi disponibili", @@ -47,6 +48,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", @@ -243,8 +246,12 @@ "edit_call_description": "Aggiorna le informazioni della chiamata", "everyone": "Tutti", "files": { + "error": "Errore durante il recupero dei file", + "file_name": "Nome 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", "title": "File chiamata" }, "geocoding_error": "Ricerca indirizzo fallita, riprovare", @@ -696,6 +703,7 @@ "add": "Aggiungi", "back": "Indietro", "cancel": "Annulla", + "clear": "Cancella", "close": "Chiudi", "confirm": "Conferma", "confirm_location": "Conferma posizione", @@ -722,6 +730,7 @@ "optional": "opzionale", "permission_denied": "Autorizzazione negata", "previous": "Precedente", + "refresh": "Aggiorna", "remove": "Rimuovi", "retry": "Riprova", "route": "Percorso", @@ -846,6 +855,7 @@ "no_lanes": "Ancora nessuna struttura di comando", "no_needs": "Nessuna necessità", "no_objectives": "Ancora nessun obiettivo", + "no_search_results_description": "Nessun incidente corrisponde alla ricerca.", "objectives": "Obiettivi", "ongoing": "In corso", "par_critical": "Critico", @@ -853,6 +863,7 @@ "personnel": "Personale", "read_only_banner": "Questo incidente è terminato: le informazioni sono di sola lettura.", "resources": "Risorse", + "search": "Cerca incidenti...", "title": "Incidenti", "unassigned": "Non assegnato", "units": "Unità", diff --git a/src/translations/pl.json b/src/translations/pl.json index 6d37725..6f7f79d 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -36,6 +36,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 +48,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", @@ -243,8 +246,12 @@ "edit_call_description": "Zaktualizuj informacje o zgłoszeniu", "everyone": "Wszyscy", "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", "title": "Pliki zgłoszenia" }, "geocoding_error": "Wyszukiwanie adresu nie powiodło się, spróbuj ponownie", @@ -696,6 +703,7 @@ "add": "Dodaj", "back": "Wstecz", "cancel": "Anuluj", + "clear": "Wyczyść", "close": "Zamknij", "confirm": "Potwierdź", "confirm_location": "Potwierdź lokalizację", @@ -722,6 +730,7 @@ "optional": "opcjonalne", "permission_denied": "Odmówiono dostępu", "previous": "Poprzedni", + "refresh": "Odśwież", "remove": "Usuń", "retry": "Spróbuj ponownie", "route": "Trasa", @@ -846,6 +855,7 @@ "no_lanes": "Brak struktury dowodzenia", "no_needs": "Brak potrzeb", "no_objectives": "Brak celów", + "no_search_results_description": "Żadne zdarzenia nie pasują do wyszukiwania.", "objectives": "Cele", "ongoing": "W toku", "par_critical": "Krytyczny", @@ -853,6 +863,7 @@ "personnel": "Personel", "read_only_banner": "Ten incydent został zakończony — informacje są tylko do odczytu.", "resources": "Zasoby", + "search": "Szukaj zdarzeń...", "title": "Zdarzenia", "unassigned": "Nieprzypisane", "units": "Jednostki", diff --git a/src/translations/sv.json b/src/translations/sv.json index 9ca6582..c30c5b3 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -36,6 +36,7 @@ "applied": "Tillämpad", "audio": "Ljud", "audioActive": "Ljud aktivt", + "audio_capable": "Ljudkompatibel", "audio_device": "BT-handset", "audio_output": "Ljudutgång", "availableDevices": "Tillgängliga enheter", @@ -47,6 +48,8 @@ "connect": "Anslut", "connected": "Ansluten", "connectionError": "Anslutningsfel", + "connection_error_message": "Kunde inte ansluta till enheten", + "connection_error_title": "Anslutningen misslyckades", "current_selection": "Aktuellt val", "device_disconnected": "Enhet frånkopplad", "disconnect": "Koppla från", @@ -243,8 +246,12 @@ "edit_call_description": "Uppdatera samtalsinformation", "everyone": "Alla", "files": { + "error": "Fel vid hämtning av filer", + "file_name": "Filnamn", "no_files": "Inga filer tillgängliga", "no_files_description": "Inga filer har lagts till i detta samtal ännu", + "open_error": "Fel vid öppning av fil", + "share_error": "Fel vid delning av fil", "title": "Samtalsfiler" }, "geocoding_error": "Det gick inte att söka efter adressen, försök igen", @@ -696,6 +703,7 @@ "add": "Lägg till", "back": "Tillbaka", "cancel": "Avbryt", + "clear": "Rensa", "close": "Stäng", "confirm": "Bekräfta", "confirm_location": "Bekräfta plats", @@ -722,6 +730,7 @@ "optional": "valfritt", "permission_denied": "Behörighet nekad", "previous": "Föregående", + "refresh": "Uppdatera", "remove": "Ta bort", "retry": "Försök igen", "route": "Rutt", @@ -846,6 +855,7 @@ "no_lanes": "Ingen ledningsstruktur ännu", "no_needs": "Inga behov", "no_objectives": "Inga mål ännu", + "no_search_results_description": "Inga händelser matchar din sökning.", "objectives": "Mål", "ongoing": "Pågående", "par_critical": "Kritisk", @@ -853,6 +863,7 @@ "personnel": "Personal", "read_only_banner": "Denna händelse har avslutats — informationen är skrivskyddad.", "resources": "Resurser", + "search": "Sök händelser...", "title": "Händelser", "unassigned": "Ej tilldelad", "units": "Enheter", diff --git a/src/translations/uk.json b/src/translations/uk.json index fbbe739..78b59c9 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -36,6 +36,7 @@ "applied": "Застосовано", "audio": "Аудіо", "audioActive": "Аудіо активне", + "audio_capable": "Підтримує аудіо", "audio_device": "BT-гарнітура", "audio_output": "Виведення звуку", "availableDevices": "Доступні пристрої", @@ -47,6 +48,8 @@ "connect": "Підключити", "connected": "Підключено", "connectionError": "Помилка підключення", + "connection_error_message": "Не вдалося підключитися до пристрою", + "connection_error_title": "Помилка підключення", "current_selection": "Поточний вибір", "device_disconnected": "Пристрій відключено", "disconnect": "Відключити", @@ -243,8 +246,12 @@ "edit_call_description": "Оновити інформацію про виклик", "everyone": "Всі", "files": { + "error": "Помилка отримання файлів", + "file_name": "Назва файлу", "no_files": "Немає доступних файлів", "no_files_description": "До цього виклику ще не додано файлів", + "open_error": "Помилка відкриття файлу", + "share_error": "Помилка надсилання файлу", "title": "Файли виклику" }, "geocoding_error": "Пошук адреси не вдався, спробуйте ще раз", @@ -696,6 +703,7 @@ "add": "Додати", "back": "Назад", "cancel": "Скасувати", + "clear": "Очистити", "close": "Закрити", "confirm": "Підтвердити", "confirm_location": "Підтвердити місцезнаходження", @@ -722,6 +730,7 @@ "optional": "необов'язково", "permission_denied": "Доступ заборонено", "previous": "Попередній", + "refresh": "Оновити", "remove": "Видалити", "retry": "Спробувати ще раз", "route": "Маршрут", @@ -846,6 +855,7 @@ "no_lanes": "Ще немає структури управління", "no_needs": "Немає потреб", "no_objectives": "Ще немає цілей", + "no_search_results_description": "Жоден інцидент не відповідає вашому пошуку.", "objectives": "Цілі", "ongoing": "Триває", "par_critical": "Критично", @@ -853,6 +863,7 @@ "personnel": "Персонал", "read_only_banner": "Цей інцидент завершено — інформація доступна лише для читання.", "resources": "Ресурси", + "search": "Пошук інцидентів...", "title": "Інциденти", "unassigned": "Не призначено", "units": "Одиниці",