diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx
index f6557c6..0ee9ba4 100644
--- a/src/__tests__/app/call/[id].security.test.tsx
+++ b/src/__tests__/app/call/[id].security.test.tsx
@@ -241,6 +241,13 @@ jest.mock('../../../components/status/status-bottom-sheet', () => ({
StatusBottomSheet: () =>
Status Sheet
,
}));
+// The dispatch selection modal is now a gluestack Actionsheet; importing the real one
+// pulls in @gluestack-ui overlay code that needs the full react-native StatusBar, which
+// this suite's minimal react-native mock does not provide. Mock it like the other sheets.
+jest.mock('../../../components/calls/dispatch-selection-modal', () => ({
+ DispatchSelectionModal: () => Dispatch Selection
,
+}));
+
jest.mock('@/components/incident-command/incident-command-tab', () => ({
IncidentCommandTab: () => Incident Command Tab
,
}));
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 68605f0..b2ecf47 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -354,7 +354,7 @@ export default function CallDetail() {
title: t('call_detail.tabs.info'),
icon: ,
content: (
-
+
{t('call_detail.priority')}
@@ -483,7 +483,7 @@ export default function CallDetail() {
{callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? (
{callExtraData.Protocols.map((protocol, index) => (
-
+
{protocol.Name}
{protocol.Description}
@@ -554,7 +554,7 @@ export default function CallDetail() {
{callExtraData?.Dispatches && callExtraData.Dispatches.length > 0 ? (
{callExtraData.Dispatches.map((dispatched, index) => (
-
+
{dispatched.Name}
@@ -655,9 +655,9 @@ export default function CallDetail() {
headerBackTitle: '',
}}
/>
-
+
{/* Header */}
-
+
{call.Name} ({call.Number})
@@ -708,9 +708,9 @@ export default function CallDetail() {
{/* Map (toggles between the call address and the destination POI when one exists) */}
-
+
{hasDestinationLocation ? (
-
+
setMapView('call')}>
{t('call_detail.map_call')}
@@ -725,7 +725,7 @@ export default function CallDetail() {
{/* Action Buttons */}
-
+
openNotesModal()} variant="outline" className="w-full" size={isLandscape ? 'md' : 'sm'}>
@@ -779,7 +779,7 @@ export default function CallDetail() {
{/* Tabs */}
-
+
diff --git a/src/app/call/[id]/command.tsx b/src/app/call/[id]/command.tsx
index 6c6a60f..f1912cc 100644
--- a/src/app/call/[id]/command.tsx
+++ b/src/app/call/[id]/command.tsx
@@ -1,5 +1,4 @@
import { Stack, useLocalSearchParams } from 'expo-router';
-import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
@@ -12,12 +11,11 @@ export default function CallCommandBoard() {
const { id } = useLocalSearchParams();
const callId = Array.isArray(id) ? id[0] : id;
const { t } = useTranslation();
- const { colorScheme } = useColorScheme();
return (
<>
-
+
{callId ? : null}
diff --git a/src/app/call/[id]/edit.tsx b/src/app/call/[id]/edit.tsx
index 8b9a05f..b8656ac 100644
--- a/src/app/call/[id]/edit.tsx
+++ b/src/app/call/[id]/edit.tsx
@@ -509,11 +509,11 @@ export default function EditCall() {
}}
/>
-
+
{t('calls.edit_call_description')}
-
+
{t('calls.name')}
@@ -535,7 +535,7 @@ export default function EditCall() {
-
+
{t('calls.nature')}
@@ -557,7 +557,7 @@ export default function EditCall() {
-
+
{t('calls.priority')}
@@ -590,7 +590,7 @@ export default function EditCall() {
-
+
{t('calls.type')}
@@ -623,7 +623,7 @@ export default function EditCall() {
-
+
{t('calls.note')}
@@ -640,7 +640,7 @@ export default function EditCall() {
-
+
{t('calls.call_location')}
{/* Address Field */}
@@ -707,7 +707,7 @@ export default function EditCall() {
-
+
{t('calls.contact_name')}
@@ -724,7 +724,7 @@ export default function EditCall() {
-
+
{t('calls.contact_info')}
@@ -742,7 +742,7 @@ export default function EditCall() {
{/* Additional Fields (UDF) */}
-
+
setIsAdditionalFieldsExpanded((prev) => !prev)}>
{t('calls.additional_fields', 'Additional Fields')}
{isAdditionalFieldsExpanded ? : }
@@ -754,14 +754,14 @@ export default function EditCall() {
) : null}
-
+
{t('calls.dispatch_to')}
setShowDispatchModal(true)} className="w-full">
{getDispatchSummary()}
-
+
{t('calls.notify_cancelled_entities')}
diff --git a/src/app/call/new/index.tsx b/src/app/call/new/index.tsx
index 810ae6a..e479a6e 100644
--- a/src/app/call/new/index.tsx
+++ b/src/app/call/new/index.tsx
@@ -767,12 +767,12 @@ export default function NewCall() {
}}
/>
-
+
{t('calls.create_new_call')}
{/* Call Templates */}
-
+
toggleSection('templates')} className="flex-row items-center justify-between p-4">
@@ -790,7 +790,7 @@ export default function NewCall() {
) : null}
-
+
toggleSection('callName')} className="flex-row items-center justify-between p-4">
{t('calls.name')}
{sectionsExpanded.callName ? : }
@@ -817,7 +817,7 @@ export default function NewCall() {
) : null}
-
+
toggleSection('nature')} className="flex-row items-center justify-between p-4">
{t('calls.nature')}
{sectionsExpanded.nature ? : }
@@ -844,7 +844,7 @@ export default function NewCall() {
) : null}
-
+
toggleSection('priorityType')} className="flex-row items-center justify-between p-4">
{t('calls.priority_and_type', 'Priority & Type')}
{sectionsExpanded.priorityType ? : }
@@ -916,7 +916,7 @@ export default function NewCall() {
{fieldPolicy.isVisible(NewCallFieldKeys.Note) ? (
-
+
toggleSection('note')} className="flex-row items-center justify-between p-4">
{t('calls.note')}
{sectionsExpanded.note ? : }
@@ -940,7 +940,7 @@ export default function NewCall() {
) : null}
{showLocationCard ? (
-
+
toggleSection('location')} className="flex-row items-center justify-between p-4">
{t('calls.call_location')}
{sectionsExpanded.location ? : }
@@ -1104,7 +1104,7 @@ export default function NewCall() {
{/* One card holds both contact fields, so it shows when either is enabled. */}
{fieldPolicy.isVisible(NewCallFieldKeys.ContactName) || fieldPolicy.isVisible(NewCallFieldKeys.ContactInfo) ? (
-
+
toggleSection('contact')} className="flex-row items-center justify-between p-4">
@@ -1158,14 +1158,14 @@ export default function NewCall() {
{/* Protocols */}
{fieldPolicy.isVisible(NewCallFieldKeys.Protocols) ? (
-
+
toggleSection('protocols')} className="flex-row items-center justify-between p-4">
{t('calls.protocols.title', 'Protocols')}
{selectedProtocols.length > 0 ? (
-
- {selectedProtocols.length}
+
+ {selectedProtocols.length}
) : null}
@@ -1186,14 +1186,14 @@ export default function NewCall() {
{/* Linked Call */}
{fieldPolicy.isVisible(NewCallFieldKeys.LinkedCall) ? (
-
+
toggleSection('linkedCall')} className="flex-row items-center justify-between p-4">
{t('calls.linked_calls.title', 'Linked Call')}
{linkedCall ? (
-
- #{linkedCall.number}
+
+ #{linkedCall.number}
) : null}
@@ -1202,7 +1202,7 @@ export default function NewCall() {
{sectionsExpanded.linkedCall ? (
{linkedCall ? (
-
+
#{linkedCall.number} — {linkedCall.name}
@@ -1221,7 +1221,7 @@ export default function NewCall() {
) : null}
{/* Additional Fields (UDF) */}
-
+
toggleSection('additionalFields')} className="flex-row items-center justify-between p-4">
{t('calls.additional_fields', 'Additional Fields')}
{sectionsExpanded.additionalFields ? (
@@ -1239,7 +1239,7 @@ export default function NewCall() {
{/* Call Form */}
{callForm ? (
-
+
toggleSection('callForm')} className="flex-row items-center justify-between p-4">
{callForm.Name || t('calls.form.title', 'Call Form')}
{sectionsExpanded.callForm ? : }
@@ -1253,7 +1253,7 @@ export default function NewCall() {
) : null}
{fieldPolicy.isVisible(NewCallFieldKeys.DispatchList) ? (
-
+
toggleSection('dispatch')} className="flex-row items-center justify-between p-4">
{t('calls.dispatch_to')}
{sectionsExpanded.dispatch ? : }
diff --git a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
index 24ddc8c..2cd88f6 100644
--- a/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-basic.test.tsx
@@ -48,6 +48,23 @@ jest.mock('nativewind', () => ({
cssInterop: jest.fn(),
}));
+// The component is now a gluestack Actionsheet (renders inside a native Modal),
+// so mock the actionsheet parts to render inline like the other sheet tests do.
+jest.mock('@/components/ui/actionsheet', () => {
+ const { View } = require('react-native');
+ return {
+ Actionsheet: ({ isOpen, children }: any) => (isOpen ? {children} : null),
+ ActionsheetBackdrop: ({ children }: any) => {children} ,
+ ActionsheetContent: ({ children, style }: any) => (
+
+ {children}
+
+ ),
+ ActionsheetDragIndicator: () => ,
+ ActionsheetDragIndicatorWrapper: ({ children }: any) => {children} ,
+ };
+});
+
// Mock cssInterop globally
(global as any).cssInterop = jest.fn();
diff --git a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
index a3276b0..657217d 100644
--- a/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
+++ b/src/components/calls/__tests__/dispatch-selection-modal.test.tsx
@@ -51,6 +51,23 @@ jest.mock('nativewind', () => ({
cssInterop: jest.fn(),
}));
+// The component is now a gluestack Actionsheet (renders inside a native Modal),
+// so mock the actionsheet parts to render inline like the other sheet tests do.
+jest.mock('@/components/ui/actionsheet', () => {
+ const { View } = require('react-native');
+ return {
+ Actionsheet: ({ isOpen, children }: any) => (isOpen ? {children} : null),
+ ActionsheetBackdrop: ({ children }: any) => {children} ,
+ ActionsheetContent: ({ children, style }: any) => (
+
+ {children}
+
+ ),
+ ActionsheetDragIndicator: () => ,
+ ActionsheetDragIndicatorWrapper: ({ children }: any) => {children} ,
+ };
+});
+
// Mock translations
jest.mock('react-i18next', () => ({
useTranslation: () => ({
diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx
index 6a2d3d6..0c66cf7 100644
--- a/src/components/calls/close-call-bottom-sheet.tsx
+++ b/src/components/calls/close-call-bottom-sheet.tsx
@@ -1,8 +1,7 @@
import { useRouter } from 'expo-router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Platform } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
+import { Platform, ScrollView } from 'react-native';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Button, ButtonText } from '@/components/ui/button';
@@ -108,10 +107,13 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
{t('call_detail.close_call')}
-
@@ -156,7 +158,7 @@ export const CloseCallBottomSheet: React.FC = ({ isOp
-
+
diff --git a/src/components/calls/dispatch-selection-modal.tsx b/src/components/calls/dispatch-selection-modal.tsx
index c0ac73b..2a768da 100644
--- a/src/components/calls/dispatch-selection-modal.tsx
+++ b/src/components/calls/dispatch-selection-modal.tsx
@@ -2,13 +2,15 @@ import { CheckIcon, SearchIcon, UsersIcon, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
-import { ScrollView, TextInput, TouchableOpacity, View } from 'react-native';
+import { ScrollView, TouchableOpacity } from 'react-native';
import { Loading } from '@/components/common/loading';
+import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { HStack } from '@/components/ui/hstack';
+import { Input, InputField } from '@/components/ui/input';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { type DispatchSelection, useDispatchStore } from '@/stores/dispatch/store';
@@ -70,225 +72,200 @@ export const DispatchSelectionModal: React.FC = ({
return selection.users.length + selection.groups.length + selection.roles.length + selection.units.length;
};
- if (!isVisible) return null;
-
return (
-
- {/* Header */}
-
-
-
- {t('calls.select_dispatch_recipients')}
-
-
-
-
-
+
+
+
+
+
+
- {/* Search */}
-
-
-
-
+ {/* Header */}
+
+
+
+
+ {t('calls.select_dispatch_recipients')}
+
+
+
+
+
-
- {/* Content */}
- {isLoading ? (
-
- ) : error ? (
-
- {error}
+ {/* Search */}
+
+
+
+
+
- ) : (
-
- {/* Partial load warning — the sections that did load are still usable. */}
- {hasLoadFailure && (
-
-
- {t('calls.dispatch_recipients_partial_load')}
- fetchDispatchData(true)}>
- {t('common.retry')}
-
-
-
- )}
- {/* Everyone Option */}
-
-
-
-
- {selection.everyone && }
-
-
- {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) && }
-
-
- {user.Name}
-
-
+ {/* Content */}
+ {isLoading ? (
+
+
+
+ ) : error ? (
+
+ {error}
+ fetchDispatchData(true)}>
+ {t('common.retry')}
+
+
+ ) : (
+
+ {/* Partial load warning — the sections that did load are still usable. */}
+ {hasLoadFailure && (
+
+
+ {t('calls.dispatch_recipients_partial_load')}
+ fetchDispatchData(true)}>
+ {t('common.retry')}
-
- ))}
-
- )}
+
+
+ )}
- {/* Groups Section */}
- {filteredData.groups.length > 0 && (
-
-
- {t('calls.groups')} ({filteredData.groups.length})
-
- {filteredData.groups.map((group) => (
-
- toggleGroup(group.Id)}>
-
-
- {selection.groups.includes(group.Id) && }
-
-
- {group.Name}
-
-
-
-
- ))}
-
- )}
+ {/* Everyone Option */}
+
+
+
+
+ {selection.everyone ? : null}
+
+
+ {t('calls.everyone')}
+ {t('calls.dispatch_to_everyone')}
+
+
+
+
- {/* Roles Section */}
- {filteredData.roles.length > 0 && (
-
-
- {t('calls.roles')} ({filteredData.roles.length})
-
- {filteredData.roles.map((role) => (
-
- toggleRole(role.Id)}>
-
-
- {selection.roles.includes(role.Id) && }
-
-
- {role.Name}
-
-
-
-
- ))}
-
- )}
+ {/* 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}
+
+
+
+
+ ))}
+
+ )}
- {/* Units Section */}
- {filteredData.units.length > 0 && (
-
-
- {t('calls.units')} ({filteredData.units.length})
-
- {filteredData.units.map((unit) => (
-
- toggleUnit(unit.Id)}>
-
-
- {selection.units.includes(unit.Id) && }
-
-
- {unit.Name}
-
-
-
-
- ))}
-
- )}
+ {/* 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}
+
+
+
+
+ ))}
+
+ )}
- {/* No Results */}
- {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 && (
-
- {t('common.no_results_found')}
-
- )}
+ {/* 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}
+
+
+
+
+ ))}
+
+ )}
- {/* Everything loaded and there is genuinely nothing to pick beyond Everyone. Say so, rather
- than leaving the dispatcher staring at a single option wondering what broke. */}
- {!searchQuery && !hasLoadFailure && data.users.length === 0 && data.groups.length === 0 && data.roles.length === 0 && data.units.length === 0 && (
-
- {t('calls.dispatch_recipients_empty')}
-
- )}
-
- )}
+ {/* 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}
+
+
+
+
+ ))}
+
+ )}
- {/* Footer */}
-
-
- {getSelectionCount()} {t('calls.selected')}
-
-
-
- {t('common.cancel')}
-
-
- {t('common.confirm')}
-
-
-
-
+ {/* No Results */}
+ {searchQuery && filteredData.users.length === 0 && filteredData.groups.length === 0 && filteredData.roles.length === 0 && filteredData.units.length === 0 && (
+
+ {t('common.no_results_found')}
+
+ )}
+
+ {/* Everything loaded and there is genuinely nothing to pick beyond Everyone. Say so, rather
+ than leaving the dispatcher staring at a single option wondering what broke. */}
+ {!searchQuery && !hasLoadFailure && data.users.length === 0 && data.groups.length === 0 && data.roles.length === 0 && data.units.length === 0 && (
+
+ {t('calls.dispatch_recipients_empty')}
+
+ )}
+
+ )}
+
+ {/* Footer */}
+
+
+ {getSelectionCount()} {t('calls.selected')}
+
+
+
+ {t('common.cancel')}
+
+
+ {t('common.confirm')}
+
+
+
+
+
);
};
diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx
index b77a037..e98583a 100644
--- a/src/components/status/status-bottom-sheet.tsx
+++ b/src/components/status/status-bottom-sheet.tsx
@@ -3,7 +3,6 @@ import { useColorScheme } from 'nativewind';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, ScrollView, TouchableOpacity } from 'react-native';
-import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { useKeyboardHeight } from '@/hooks/use-keyboard-height';
import { DestinationEntityType, type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities, getEnabledDestinationTabs } from '@/lib/destination-helpers';
@@ -738,12 +737,11 @@ export const StatusBottomSheet = () => {
)}
{currentStep === 'add-note' && (
-
+ /* Plain ScrollView on purpose: the sheet already slides above the keyboard via
+ 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. */
+
{/* Selected Status */}
@@ -781,7 +779,7 @@ export const StatusBottomSheet = () => {
-
+
)}
diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts
index 00ecac3..1cc6c6a 100644
--- a/src/hooks/use-keyboard-height.ts
+++ b/src/hooks/use-keyboard-height.ts
@@ -4,12 +4,15 @@ import { Keyboard, Platform } from 'react-native';
/**
* Current soft-keyboard height in dp, or 0 when it is closed.
*
- * Bottom sheets render inside a native `Modal`, which owns its own window.
- * react-native-keyboard-controller's inset animations are bound to the main window,
- * so `KeyboardAvoidingView`/`KeyboardAwareScrollView` never move sheet content and the
- * keyboard sits on top of it. React Native's own `Keyboard` events are dispatched
- * regardless of which window is focused, so they still describe the keyboard correctly
- * inside a sheet — use them to size the gap the sheet needs to leave.
+ * Bottom sheets render inside a native `Modal`, which owns its own window. The modal
+ * window itself never resizes or pans for the keyboard on either platform, so the sheet
+ * must leave the gap itself — pad `ActionsheetContent` by this height and the
+ * content-sized, bottom-anchored sheet slides up out from under the keyboard.
+ *
+ * IMPORTANT: this must be the ONLY keyboard compensation inside a sheet. Do NOT nest a
+ * `KeyboardAwareScrollView`/`KeyboardAvoidingView` in sheet content: keyboard events are
+ * window-agnostic on both platforms, so those components still fire inside the modal and
+ * shift the content a second keyboard-height, pushing it out of the visible sheet.
*
* iOS gets the `Will` events so the sheet moves with the keyboard animation; Android only
* reports usable frames on `Did`.
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index b7f6cfa..4f774bf 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -194,6 +194,40 @@ interface SignalRState {
* These are defined at module scope to ensure they're the same reference
* for both registering and unregistering
*/
+/**
+ * A call id is a non-empty string or a finite number and nothing else. Anything looser gets
+ * stringified into a plausible-looking id — an array of one becomes its element, an object becomes
+ * "[object Object]" — and would be treated as a real incident instead of falling through to the
+ * fallback path.
+ */
+function toCallId(value: unknown): string | undefined {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+ return undefined;
+}
+
+/**
+ * The affected incident's call id. Core sends it as a bare string — the eventing worker forwards the
+ * topic's ItemId, which is CallId.ToString() — with object payloads tolerated so a producer sending a
+ * richer message keeps working.
+ */
+function extractCommandCallId(message: unknown): string | undefined {
+ const scalar = toCallId(message);
+ if (scalar !== undefined) {
+ return scalar;
+ }
+ if (message !== null && typeof message === 'object') {
+ const m = message as { CallId?: unknown; callId?: unknown };
+ return toCallId(m.CallId ?? m.callId);
+ }
+ return undefined;
+}
+
interface EventHandlers {
personnelStatusUpdated: ((data: unknown) => void) | null;
personnelStaffingUpdated: ((data: unknown) => void) | null;
@@ -225,6 +259,27 @@ let updateHubHandlers: EventHandlers = {
onConnected: null,
};
+// Rejoining the department group after an update-hub reconnect.
+const UPDATE_REJOIN_RETRY_MS = 5000;
+const UPDATE_REJOIN_MAX_ATTEMPTS = 3;
+let updateRejoinTimer: ReturnType | null = null;
+let updateRejoinAttempts = 0;
+// Stamps each rejoin with the connection lifecycle that started it. Teardown (explicit
+// disconnect or a dropped transport) bumps the generation, so an invoke still in flight
+// against the old connection completes as a no-op instead of restoring the connected
+// flag, reloading incident command, or scheduling retries after the connection is gone.
+let updateConnectionGeneration = 0;
+// The rejoin in flight for the current generation, so overlapping reconnect events share
+// one announce instead of racing each other's retry budget.
+let updateRejoinOperation: { generation: number; promise: Promise } | null = null;
+
+function stopUpdateRejoinRetry(): void {
+ if (updateRejoinTimer) {
+ clearTimeout(updateRejoinTimer);
+ updateRejoinTimer = null;
+ }
+}
+
/**
* Lifecycle listeners for the update hub (reconnect/disconnect), kept apart from the method handlers
* so they can be torn down without touching the event subscriptions.
@@ -527,9 +582,9 @@ export const useSignalRStore = create((set, get) => ({
message: 'incidentCommandUpdated',
context: { message },
});
- // Payload is the affected call id (string). It is a lightweight "something changed for this
- // call" notification — the client refetches the affected command board.
- const callId = extractAlertId(message);
+ // Payload is the affected call id. extractAlertId reads alert-shaped keys and accepts blanks,
+ // so the command feed gets its own strict reader.
+ const callId = extractCommandCallId(message);
const now = Date.now();
set({ lastUpdateMessage: null, lastUpdateTimestamp: now, lastEventType: 'incidentCommandUpdated', lastIncidentCommandUpdateTimestamp: now });
if (callId) {
@@ -557,33 +612,90 @@ export const useSignalRStore = create((set, get) => ({
unregisterUpdateHubLifecycleHandlers();
const updateReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`;
- const onUpdateReconnected = () => {
+
+ /**
+ * Rejoin the department group, retrying a few times before giving up.
+ *
+ * A failed rejoin is silent and total: the socket is up, so nothing looks wrong, but the
+ * connection belongs to no group and no board change will ever arrive. Nothing else clears the
+ * connected flag either — an automatic reconnect never raises the disconnected event, only a
+ * close does — so it has to be cleared here or connectUpdateHub's already-connected guard
+ * would block every later repair.
+ */
+ const runUpdateRejoin = async (generation: number): Promise => {
const reconnectDepartmentId = Number(securityStore.getState().rights?.DepartmentId ?? '0');
if (!Number.isFinite(reconnectDepartmentId)) {
return;
}
- signalRService
- .invoke(Env.CHANNEL_HUB_NAME, 'connect', reconnectDepartmentId)
- .then(() => {
- set({ isUpdateHubConnected: true, error: null });
- logger.info({ message: 'Re-announced to update hub after reconnect; reloading incident command', context: { departmentId: reconnectDepartmentId } });
- // Lazy import to avoid circular dependency
- const { useIncidentCommandStore } = require('../incident-command/store');
- const openCallId = useIncidentCommandStore.getState().callId;
- if (openCallId) {
- useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId);
- }
- })
- .catch((error) => {
- logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error } });
- });
+ try {
+ await signalRService.invoke(Env.CHANNEL_HUB_NAME, 'connect', reconnectDepartmentId);
+ // A completion from a torn-down connection must not restore state or reload.
+ if (generation !== updateConnectionGeneration) {
+ return;
+ }
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ set({ isUpdateHubConnected: true, error: null });
+ logger.info({ message: 'Re-announced to update hub after reconnect; reloading incident command', context: { departmentId: reconnectDepartmentId } });
+ // Lazy import to avoid circular dependency
+ const { useIncidentCommandStore } = require('../incident-command/store');
+ const openCallId = useIncidentCommandStore.getState().callId;
+ if (openCallId) {
+ useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId);
+ }
+ } catch (error) {
+ // A stale failure must not schedule retries against a connection that is gone.
+ if (generation !== updateConnectionGeneration) {
+ return;
+ }
+ updateRejoinAttempts += 1;
+ logger.warn({ message: 'Failed to re-announce to update hub after reconnect', context: { error, attempt: updateRejoinAttempts, maxAttempts: UPDATE_REJOIN_MAX_ATTEMPTS } });
+ set({ isUpdateHubConnected: false });
+
+ if (updateRejoinAttempts < UPDATE_REJOIN_MAX_ATTEMPTS) {
+ stopUpdateRejoinRetry();
+ updateRejoinTimer = setTimeout(() => {
+ updateRejoinTimer = null;
+ rejoinDepartmentGroup();
+ }, UPDATE_REJOIN_RETRY_MS);
+ } else {
+ logger.error({ message: 'Giving up re-announcing to update hub; the next connectUpdateHub will rebuild the session', context: { attempts: updateRejoinAttempts } });
+ }
+ }
+ };
+
+ const rejoinDepartmentGroup = () => {
+ const generation = updateConnectionGeneration;
+ // Reuse the in-flight rejoin only when it belongs to this connection; an operation
+ // left over from a previous generation is a dead announce that must not absorb the
+ // fresh connection's rejoin.
+ if (updateRejoinOperation && updateRejoinOperation.generation === generation) {
+ return;
+ }
+ const promise = runUpdateRejoin(generation).finally(() => {
+ if (updateRejoinOperation?.promise === promise) {
+ updateRejoinOperation = null;
+ }
+ });
+ updateRejoinOperation = { generation, promise };
+ };
+
+ const onUpdateReconnected = () => {
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
+ rejoinDepartmentGroup();
};
updateHubLifecycleHandlers[updateReconnected] = onUpdateReconnected;
signalRService.on(updateReconnected, onUpdateReconnected);
const updateDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHANNEL_HUB_NAME}`;
const onUpdateDisconnected = () => {
- // Clearing the flag is what lets connectUpdateHub rebuild the session later.
+ // A dropped transport supersedes any rejoin still pending against the old connection —
+ // bumping the generation turns an invoke already in flight into a no-op — and
+ // clearing the flag is what lets connectUpdateHub rebuild the session later.
+ updateConnectionGeneration += 1;
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
set({ isUpdateHubConnected: false });
};
updateHubLifecycleHandlers[updateDisconnected] = onUpdateDisconnected;
@@ -608,7 +720,12 @@ export const useSignalRStore = create((set, get) => ({
},
disconnectUpdateHub: async () => {
try {
+ // Invalidate any rejoin still in flight so its completion can't restore the
+ // connected flag or reload incident command after this teardown.
+ updateConnectionGeneration += 1;
// Unregister all handlers BEFORE disconnecting to prevent memory leaks
+ stopUpdateRejoinRetry();
+ updateRejoinAttempts = 0;
unregisterUpdateHubHandlers();
unregisterUpdateHubLifecycleHandlers();