From cd9b267136ff5f3c3db9500a076a8f15918f6e96 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Mon, 10 Aug 2026 13:49:44 -0700 Subject: [PATCH 1/4] RG-T117 IC fixes and chat fix --- src/api/chat/chat.ts | 16 +- src/app/(app)/_layout.tsx | 32 ++-- src/app/(app)/chat.tsx | 20 +-- src/app/(app)/chatbot.tsx | 24 +-- src/app/(app)/command.tsx | 167 ++++++++++++++---- src/app/chat/[channelId].tsx | 52 +++++- src/components/chat/message-actions-sheet.tsx | 32 +++- src/components/command/assistant-sheet.tsx | 8 +- .../command/landscape-structure-board.tsx | 117 ++++++++---- src/components/command/lane-details-sheet.tsx | 16 +- src/components/command/resource-cards.tsx | 11 +- src/components/command/structure-section.tsx | 46 ++++- .../ui/__tests__/bottom-sheet.test.tsx | 60 ++++++- src/components/ui/bottom-sheet.tsx | 15 +- src/components/ui/side-drawer.tsx | 18 +- .../use-command-board-layout.test.ts | 70 ++++++++ src/hooks/use-command-board-layout.ts | 52 ++++++ src/hooks/use-direct-message.ts | 48 +++++ src/models/v4/chat/chatEnums.ts | 2 + src/stores/chat/store.ts | 25 +++ src/translations/ar.json | 17 ++ src/translations/de.json | 17 ++ src/translations/en.json | 17 ++ src/translations/es.json | 17 ++ src/translations/fr.json | 17 ++ src/translations/it.json | 17 ++ src/translations/pl.json | 17 ++ src/translations/sv.json | 17 ++ src/translations/uk.json | 17 ++ 29 files changed, 841 insertions(+), 143 deletions(-) create mode 100644 src/hooks/__tests__/use-command-board-layout.test.ts create mode 100644 src/hooks/use-command-board-layout.ts create mode 100644 src/hooks/use-direct-message.ts diff --git a/src/api/chat/chat.ts b/src/api/chat/chat.ts index f939930..15ac689 100644 --- a/src/api/chat/chat.ts +++ b/src/api/chat/chat.ts @@ -31,9 +31,21 @@ const MODERATION = '/ChatModeration'; // Channels // --------------------------------------------------------------------------- -export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => { +/** + * The caller's channels. `includeArchived` pulls in the point-in-time record of closed incidents and + * calls — off by default so the everyday list stays current. + */ +export const getChannels = async (activeUnitId?: number, includeArchived = false, signal?: AbortSignal) => { + const params: Record = {}; + if (activeUnitId != null) { + params.activeUnitId = activeUnitId; + } + if (includeArchived) { + params.includeArchived = true; + } + const response = await api.get>(`${CHAT}/GetChannels`, { - params: activeUnitId != null ? { activeUnitId } : undefined, + params: Object.keys(params).length > 0 ? params : undefined, signal, }); return response.data; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index f67eeba..1b57f5d 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -501,24 +501,29 @@ export default function TabLayout() { [t, headerLeftBack, headerRightNotification] ); - // chat + chatbot are routable (sidebar menu links) but hidden from the tab bar (href: null); - // each screen renders its own in-screen header/toolbar, so the tab header is disabled. + // chat + chatbot are routable (sidebar menu links) but hidden from the tab bar (href: null). + // They keep the app header: it is the only way back out, since neither is on the tab bar and + // their in-screen toolbars carry actions rather than navigation. const chatOptions = useMemo( () => ({ href: null, title: t('chat.title'), - headerShown: false as const, + headerShown: true as const, + headerLeft: headerLeftMap, + headerRight: headerRightNotification, }), - [t] + [t, headerLeftMap, headerRightNotification] ); const chatbotOptions = useMemo( () => ({ href: null, title: t('chatbot.title'), - headerShown: false as const, + headerShown: true as const, + headerLeft: headerLeftMap, + headerRight: headerRightNotification, }), - [t] + [t, headerLeftMap, headerRightNotification] ); // settings stays routable (sidebar menu link) but is hidden from the tab bar. @@ -628,14 +633,17 @@ interface CreateDrawerMenuButtonProps { const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { return ( { setIsOpen(true); }} > - + {/* Routed through the Icon wrapper, not a bare lucide element: className alone never reaches a + raw lucide icon (no cssInterop is registered for them), so it falls back to currentColor and + renders solid black — invisible against a dark header. */} + ); }; @@ -643,8 +651,8 @@ const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { const CreateHeaderBackButton = () => { return ( { if (router.canGoBack()) { @@ -654,7 +662,7 @@ const CreateHeaderBackButton = () => { } }} > - + ); }; diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 3ef5ba8..561d581 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,5 +1,5 @@ -import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; -import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native'; +import { type Href, Redirect, useFocusEffect, useRouter } from 'expo-router'; +import { Bot, MessageCircle, Network, Plus, Sparkles, Users } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { RefreshControl, ScrollView } from 'react-native'; @@ -119,7 +119,6 @@ export default function ChatScreen() { if (chatStatus === 'unknown') { return ( - @@ -133,17 +132,14 @@ export default function ChatScreen() { return ( - - {/* In-screen toolbar (the app drawer provides the top nav bar). */} - - - - {t('chat.title')} - - router.push('/chatbot' as Href)} accessibilityLabel={t('chat.assistant')}> - + {/* Shortcut across to the assistant. The app header above carries the title and the way back, + so this row is actions only. */} + + router.push('/chatbot' as Href)} accessibilityLabel={t('chat.assistant')} testID="chat-open-assistant"> + + {t('chat.assistant')} diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 3882d46..c9d5254 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,4 +1,4 @@ -import { Redirect, Stack, useFocusEffect } from 'expo-router'; +import { Redirect, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,7 +17,6 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; -import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; @@ -67,7 +66,14 @@ export default function ChatbotScreen() { const renderItem = useCallback( ({ item }: { item: ChatMessageResultData }) => ( - undefined} /> + undefined} + /> ), [currentUserId] ); @@ -76,7 +82,6 @@ export default function ChatbotScreen() { if (chatStatus === 'unknown') { return ( - @@ -90,19 +95,16 @@ export default function ChatbotScreen() { return ( - - {/* Distinct assistant header */} + {/* Assistant identity strip. The title lives in the app header above, so this keeps only the + mark, the one-line description, and the reset action. */} - + - - {t('chatbot.title')} - {t('chatbot.subtitle')} - + {t('chatbot.subtitle')} useChatStore.getState().newChatbotSession()} accessibilityLabel={t('chatbot.new_session')}> diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx index 2160b7b..f441b25 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -1,8 +1,27 @@ import { router } from 'expo-router'; -import { ClipboardList, CloudOff, ExternalLink, Image as ImageIcon, Info, MapPin, Paperclip, Pencil, RefreshCw, Sparkles, StickyNote, Trash2, UserCog, Video as VideoIcon, XCircle } from 'lucide-react-native'; +import { + ClipboardList, + CloudOff, + ExternalLink, + Image as ImageIcon, + Info, + MapPin, + MessageCircle, + MessagesSquare, + Paperclip, + Pencil, + RefreshCw, + Sparkles, + StickyNote, + Trash2, + UserCog, + Users, + Video as VideoIcon, + XCircle, +} from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ScrollView, useWindowDimensions } from 'react-native'; +import { ScrollView } from 'react-native'; import { VideoFeedTabContent } from '@/components/call-video-feeds/video-feed-tab-content'; import CallFilesModal from '@/components/calls/call-files-modal'; @@ -45,11 +64,15 @@ import { Icon } from '@/components/ui/icon'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useCommandBoardLayout } from '@/hooks/use-command-board-layout'; +import { useDirectMessage } from '@/hooks/use-direct-message'; import { getIncidentRoleName } from '@/lib/incident-command-utils'; import { isWeb } from '@/lib/platform'; +import { ChatChannelType } from '@/models/v4/chat'; import { type IncidentNeedStatus, type ResourceAssignment, ResourceAssignmentKind } from '@/models/v4/incidentCommand/incidentCommandModels'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; +import { useChatStore } from '@/stores/chat/store'; import { type AssignmentOutcome } from '@/stores/command/store'; import { useCommandStore } from '@/stores/command/store'; import { useRolesStore } from '@/stores/roles/store'; @@ -61,7 +84,13 @@ const oneLine = isWeb ? ({ isTruncated: true } as const) : ({ numberOfLines: 1 } export default function CommandBoard() { const { t } = useTranslation(); - const { height: viewportHeight, width: viewportWidth } = useWindowDimensions(); + const { height: viewportHeight, width: viewportWidth, isRoomy, isLandscapeBoard } = useCommandBoardLayout(); + + // Phones in portrait keep the compact controls so the header doesn't crowd out the board; tablets, + // landscape and desktop-sized windows get full-height buttons that are actually easy to hit. + const controlSize = isRoomy ? 'md' : 'xs'; + const iconButtonClass = isRoomy ? 'px-4' : 'px-3'; + const showLabels = isRoomy; const boards = useCommandStore((state) => state.boards); const activeBoardCallId = useCommandStore((state) => state.activeCallId); const switchCommand = useCommandStore((state) => state.switchCommand); @@ -148,7 +177,6 @@ export default function CommandBoard() { const boardList = useMemo(() => Object.values(boards), [boards]); const boardState = activeBoardCallId ? boards[activeBoardCallId] : undefined; - const isLandscapeBoard = viewportWidth > viewportHeight && Math.min(viewportWidth, viewportHeight) >= 600; // Unit and personnel rosters back the resource pool — load once when a board is open useEffect(() => { @@ -170,9 +198,39 @@ export default function CommandBoard() { fetchTimeline(boardCallId); fetchVoiceChannels(boardCallId); fetchTransmissionLog(boardCallId); + // Chat channels for the incident (command + one per lane). Archived ones are included so a + // closed incident's conversation is still readable. + void useChatStore.getState().loadIncidentChannels(boardCallId); } }, [boardCallId, fetchTimeline, fetchVoiceChannels, fetchTransmissionLog]); + const incidentChannels = useChatStore((state) => (boardCallId ? state.incidentChannelsByCallId[boardCallId] : undefined)); + + const commandChatChannelId = useMemo(() => incidentChannels?.find((channel) => channel.ChannelType === ChatChannelType.IncidentCommand)?.ChatChannelId ?? null, [incidentChannels]); + + const laneChatChannelId = useCallback((nodeId: string) => incidentChannels?.find((channel) => channel.CommandStructureNodeId === nodeId)?.ChatChannelId ?? null, [incidentChannels]); + + const openChatChannel = useCallback( + (channelId: string | null, unavailableMessage: string) => { + if (!channelId) { + showToast('info', unavailableMessage); + return; + } + router.push(`/chat/${channelId}`); + }, + [showToast] + ); + + const handleOpenCommandChat = useCallback(() => openChatChannel(commandChatChannelId, t('command.command_chat_unavailable')), [openChatChannel, commandChatChannelId, t]); + + const leadsChatChannelId = useMemo(() => incidentChannels?.find((channel) => channel.ChannelType === ChatChannelType.IncidentLeads)?.ChatChannelId ?? null, [incidentChannels]); + + const handleOpenLeadsChat = useCallback(() => openChatChannel(leadsChatChannelId, t('command.leads_chat_unavailable')), [openChatChannel, leadsChatChannelId, t]); + + const { openDirectMessage } = useDirectMessage(); + + const handleOpenLaneChat = useCallback((nodeId: string) => openChatChannel(laneChatChannelId(nodeId), t('command.lane_chat_unavailable')), [openChatChannel, laneChatChannelId, t]); + const personName = useCallback( (userId: string) => { const user = users.find((u) => u.UserId === userId); @@ -520,11 +578,11 @@ export default function CommandBoard() { return ( - + {/* Board switcher — the IC may be running several incidents at once */} {boardList.length > 1 ? ( - + {boardList.map((b) => ( ) : null} - - + - - - - {/* Assistant: answers board questions on-device first, so it stays useful with no signal */} - - {/* Icon-only by design; a confirmation dialog guards against accidental taps. */} - + {/* A confirmation dialog guards against accidental taps. */} + {/* Quick access to the underlying call's notes/images/files/video without leaving the board */} - - - - - @@ -685,6 +767,7 @@ export default function CommandBoard() { onAddLane={() => setIsLaneSheetOpen(true)} onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} onEditLane={(nodeId) => setEditLaneNodeId(nodeId)} + onOpenLaneChat={handleOpenLaneChat} onMoveResource={handleMoveResource} onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })} resolveResourceName={resolveResourceName} @@ -698,6 +781,7 @@ export default function CommandBoard() { onAddLane={() => setIsLaneSheetOpen(true)} onAssignResource={(nodeId) => setAssignTargetNodeId(nodeId)} onEditLane={(nodeId) => setEditLaneNodeId(nodeId)} + onOpenLaneChat={handleOpenLaneChat} onMoveResource={handleMoveResource} onViewResource={(assignment) => setViewResource({ assignment, context: 'lane' })} resolveLeadName={resolveLeadName} @@ -777,8 +861,18 @@ export default function CommandBoard() { {assignment.IncidentRoleAssignmentId.startsWith('local-') ? : null} - removeRole(boardState.callId, assignment.IncidentRoleAssignmentId)} className="p-2" testID={`assignment-remove-${assignment.IncidentRoleAssignmentId}`}> - + {/* 1:1 with whoever holds this ICS position. */} + void openDirectMessage(assignment.UserId)} + className="p-3" + hitSlop={8} + testID={`assignment-message-${assignment.IncidentRoleAssignmentId}`} + > + + + removeRole(boardState.callId, assignment.IncidentRoleAssignmentId)} className="p-3" hitSlop={8} testID={`assignment-remove-${assignment.IncidentRoleAssignmentId}`}> + @@ -905,6 +999,7 @@ export default function CommandBoard() { maps={boardState.board?.Maps ?? []} users={users} onSave={(nodeId, patch) => updateNodeDetails(boardState.callId, nodeId, patch)} + onMessageLead={(userId: string) => void openDirectMessage(userId)} resourceCount={(boardState.board?.Assignments ?? []).filter((a) => !a.ReleasedOn && a.CommandStructureNodeId === editLaneNodeId).length} onDelete={(nodeId, disposition) => void handleDeleteLane(nodeId, disposition)} /> diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index c7095f7..97be603 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -1,6 +1,6 @@ import { Image } from 'expo-image'; import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; -import { Circle, ShieldCheck } from 'lucide-react-native'; +import { Archive, ArrowLeft, Circle, ShieldCheck } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FlatList, Platform } from 'react-native'; @@ -18,7 +18,9 @@ import { Box } from '@/components/ui/box'; import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; +import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; @@ -75,6 +77,35 @@ export default function ChannelConversationScreen() { // The server validates the user actually holds command (CanSendAsIcAsync) and rejects otherwise. const isCommandChannel = isCommandChannelType(channel?.ChannelType); + /** + * Archived channel = point-in-time record. A closed incident freezes its command and lane chat, and + * a closed call freezes its incident chat: no posting, no editing, no reactions. The server enforces + * all of it; this just stops the UI offering actions that would bounce. Flagging stays available. + */ + const isFrozen = !!channel?.IsArchived; + + /** + * Back always lands on the chat list. router.back() alone is not enough — this screen is routinely + * entered from a push notification or a deep link with no history to pop, which leaves the default + * header back button absent entirely. + */ + const handleBack = useCallback(() => { + if (router.canGoBack()) { + router.back(); + return; + } + router.replace('/chat' as Href); + }, [router]); + + const headerLeftBack = useCallback( + () => ( + + + + ), + [handleBack, t] + ); + // Newest-first for the inverted list. const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); @@ -229,11 +260,11 @@ export default function ChannelConversationScreen() { const handleToggleReaction = useCallback( (message: ChatMessageResultData, emoji: string, mine: boolean) => { - if (!channelId) return; + if (!channelId || isFrozen) return; if (mine) void useChatStore.getState().removeReaction(message.ChatMessageId, channelId, emoji); else void useChatStore.getState().addReaction(message.ChatMessageId, channelId, emoji); }, - [channelId] + [channelId, isFrozen] ); const openThread = useCallback( @@ -272,7 +303,7 @@ export default function ChannelConversationScreen() { if (chatStatus === 'unknown') { return ( - + ); @@ -288,7 +319,7 @@ export default function ChannelConversationScreen() { if (!isResolved) { return ( - + ); @@ -307,10 +338,18 @@ export default function ChannelConversationScreen() { title, headerShown: true, headerBackTitle: '', + headerLeft: headerLeftBack, headerRight: () => (isDm ? : undefined), }} /> + {isFrozen ? ( + + + {t('chat.frozen_notice')} + + ) : null} + {/* IC delta: identity chip — messages in command channels post as the Incident Commander. */} {isCommandChannel ? ( @@ -347,7 +386,7 @@ export default function ChannelConversationScreen() { onSendLocation={handleSendLocation} onOpenGif={() => setGifOpen(true)} onTyping={(isTyping) => channelId && useChatStore.getState().sendTyping(channelId, isTyping)} - disabled={channel?.IsLocked && !isModerator} + disabled={isFrozen || (channel?.IsLocked && !isModerator)} /> @@ -359,6 +398,7 @@ export default function ChannelConversationScreen() { onClose={() => setActionsMessage(null)} isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId} isModerator={isModerator} + frozen={isFrozen} onReact={(m, emoji) => handleToggleReaction( m, diff --git a/src/components/chat/message-actions-sheet.tsx b/src/components/chat/message-actions-sheet.tsx index c6ee317..a19309b 100644 --- a/src/components/chat/message-actions-sheet.tsx +++ b/src/components/chat/message-actions-sheet.tsx @@ -18,6 +18,12 @@ interface MessageActionsSheetProps { isModerator: boolean; /** Assistant conversations: no reactions, threads, deletes or edits — copy, pin and flag stay. */ assistant?: boolean; + /** + * Point-in-time record (a closed incident's command/lane chat, a closed call): the history can no + * longer be changed, so reactions, threads, edits and self-deletes go away. Copy stays, and so does + * every moderation path — flagging and moderator delete must keep working after an incident closes. + */ + frozen?: boolean; onReact: (message: ChatMessageResultData, emoji: string) => void; onReply: (message: ChatMessageResultData) => void; onCopy: (message: ChatMessageResultData) => void; @@ -28,7 +34,23 @@ interface MessageActionsSheetProps { onModeratorDelete: (message: ChatMessageResultData) => void; } -export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerator, assistant = false, onReact, onReply, onCopy, onEdit, onDelete, onFlag, onTogglePin, onModeratorDelete }: MessageActionsSheetProps) { +export function MessageActionsSheet({ + message, + isOpen, + onClose, + isOwn, + isModerator, + assistant = false, + frozen = false, + onReact, + onReply, + onCopy, + onEdit, + onDelete, + onFlag, + onTogglePin, + onModeratorDelete, +}: MessageActionsSheetProps) { const { t } = useTranslation(); const [mode, setMode] = useState<'actions' | 'flag'>('actions'); @@ -77,7 +99,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : ( <> - {!isDeleted && !assistant ? ( + {!isDeleted && !assistant && !frozen ? ( {QUICK_REACTIONS.map((emoji) => ( ) : null} - {!assistant ? ( + {!assistant && !frozen ? ( { onReply(message); @@ -118,7 +140,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : null} - {isOwn && isText && !isDeleted && !assistant ? ( + {isOwn && isText && !isDeleted && !assistant && !frozen ? ( { onEdit(message); @@ -130,7 +152,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat ) : null} - {isOwn && !isDeleted && !assistant ? ( + {isOwn && !isDeleted && !assistant && !frozen ? ( { onDelete(message); diff --git a/src/components/command/assistant-sheet.tsx b/src/components/command/assistant-sheet.tsx index 060f1cf..10bc543 100644 --- a/src/components/command/assistant-sheet.tsx +++ b/src/components/command/assistant-sheet.tsx @@ -69,7 +69,7 @@ export const IncidentAssistantSheet: React.FC = ({ - + @@ -99,7 +99,7 @@ export const IncidentAssistantSheet: React.FC = ({ {suggestions.map((suggestion) => ( submit(suggestion.question)} testID={`incident-assistant-suggestion-${suggestion.question}`} > @@ -119,7 +119,7 @@ export const IncidentAssistantSheet: React.FC = ({ {messages.map((entry) => entry.role === 'user' ? ( - + {entry.text} ) : ( @@ -156,7 +156,7 @@ export const IncidentAssistantSheet: React.FC = ({ = ({ children, testID }) => { + const [viewportHeight, setViewportHeight] = useState(0); + const [contentHeight, setContentHeight] = useState(0); + + // A pixel of slack so rounding never leaves a non-overflowing lane capturing gestures. + const overflows = viewportHeight > 0 && contentHeight > viewportHeight + 1; + + return ( + setContentHeight(height)} + onLayout={(event) => setViewportHeight(event.nativeEvent.layout.height)} + scrollEnabled={overflows} + showsVerticalScrollIndicator={overflows} + testID={testID} + > + {children} + + ); +}; + interface LandscapeStructureBoardProps { nodes: CommandStructureNode[]; assignments: ResourceAssignment[]; @@ -88,6 +118,8 @@ interface LandscapeStructureBoardProps { onAddLane: () => void; /** Open the lane details editor (leads, linked objectives/need, delete). */ onEditLane?: (nodeId: string) => void; + /** Open the lane's own chat channel. Omitted when the board has no chat. */ + onOpenLaneChat?: (nodeId: string) => void; onAssignResource: (nodeId: string) => void; onMoveResource: (assignmentId: string, targetNodeId: string) => void | Promise; /** Opens the resource details sheet for a lane assignment (hosts remove-from-lane). */ @@ -211,7 +243,7 @@ const DraggableResourceCard: React.FC = React.memo( > - + {name} @@ -223,13 +255,13 @@ const DraggableResourceCard: React.FC = React.memo( redAfterMinutes={redAfterMinutes} testID={`landscape-worktime-${assignment.ResourceAssignmentId}`} /> - - + + {assignment.ResourceAssignmentId.startsWith('local-') || assignment.RequirementsWarning || isSelected ? ( - {assignment.ResourceAssignmentId.startsWith('local-') ? : null} + {assignment.ResourceAssignmentId.startsWith('local-') ? : null} {assignment.RequirementsWarning ? ( {t('command.requirements_warning')} @@ -259,6 +291,7 @@ export const LandscapeStructureBoard: React.FC = ( resolveResourceName, onAddLane, onEditLane, + onOpenLaneChat, onAssignResource, onMoveResource, onViewResource, @@ -273,7 +306,10 @@ export const LandscapeStructureBoard: React.FC = ( const selectedAssignment = activeAssignments.find((assignment) => assignment.ResourceAssignmentId === selectedAssignmentId); const totalGapWidth = Math.max(activeNodes.length - 1, 0) * 12; const laneWidth = Math.min(320, Math.max(220, (viewportWidth - 64 - totalGapWidth) / Math.max(activeNodes.length, 1))); - const laneHeight = Math.max(320, viewportHeight - 360); + // A FIXED height, clamped so the board never eats more than a screenful. Previously this was a + // minHeight, so a lane with a lot of crews grew without bound and the page scrolled forever to get + // past the structure section. Each lane now scrolls its own resources instead. + const laneHeight = Math.max(280, Math.min(560, viewportHeight - 360)); const moveAssignment = useCallback( async (assignmentId: string, targetNodeId: string) => { @@ -344,7 +380,7 @@ export const LandscapeStructureBoard: React.FC = ( {activeNodes.length === 0 ? ( {t('command.empty_structure')} ) : ( - + {activeNodes.map((node) => { const laneAssignments = activeAssignments.filter((assignment) => assignment.CommandStructureNodeId === node.CommandStructureNodeId); @@ -357,7 +393,7 @@ export const LandscapeStructureBoard: React.FC = ( ref={(lane) => { laneRefs.current[node.CommandStructureNodeId] = lane; }} - style={{ minHeight: laneHeight, width: laneWidth }} + style={{ height: laneHeight, width: laneWidth }} > = ( {t('command.lane_understaffed', { count: laneUnitCount, min: node.MinUnits })} ) : null} - {node.CommandStructureNodeId.startsWith('local-') ? : null} + {node.CommandStructureNodeId.startsWith('local-') ? : null} + {onOpenLaneChat ? ( + onOpenLaneChat(node.CommandStructureNodeId)} + testID={`landscape-lane-chat-${node.CommandStructureNodeId}`} + > + + + ) : null} {onEditLane ? ( onEditLane(node.CommandStructureNodeId)} testID={`landscape-lane-edit-${node.CommandStructureNodeId}`} > - + ) : null} onAssignResource(node.CommandStructureNodeId)} testID={`landscape-lane-assign-${node.CommandStructureNodeId}`} > - + @@ -412,24 +462,29 @@ export const LandscapeStructureBoard: React.FC = ( {laneAssignments.length === 0 ? ( {t('command.no_resources_in_lane')} ) : ( - - {laneAssignments.map((assignment) => ( - setDraggingAssignmentId(null)} - onDragStart={setDraggingAssignmentId} - onDrop={(assignmentId, pageX, pageY) => void handleDrop(assignmentId, pageX, pageY)} - onView={onViewResource} - onSelect={handleSelect} - /> - ))} - + // The lane is a fixed height, so a busy lane scrolls its own crews rather than + // stretching the whole board. directionalLockEnabled keeps a sideways drag going + // to the lane strip instead of being eaten here. + + + {laneAssignments.map((assignment) => ( + setDraggingAssignmentId(null)} + onDragStart={setDraggingAssignmentId} + onDrop={(assignmentId, pageX, pageY) => void handleDrop(assignmentId, pageX, pageY)} + onView={onViewResource} + onSelect={handleSelect} + /> + ))} + + )} {draggingAssignmentId && laneAssignments.every((assignment) => assignment.ResourceAssignmentId !== draggingAssignmentId) ? ( diff --git a/src/components/command/lane-details-sheet.tsx b/src/components/command/lane-details-sheet.tsx index d10172a..a2b42f9 100644 --- a/src/components/command/lane-details-sheet.tsx +++ b/src/components/command/lane-details-sheet.tsx @@ -1,3 +1,4 @@ +import { MessageCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView } from 'react-native'; @@ -6,7 +7,9 @@ import { CustomBottomSheet } from '@/components/ui/bottom-sheet'; import { Button, ButtonText } from '@/components/ui/button'; import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; +import { Icon } from '@/components/ui/icon'; import { Input, InputField } from '@/components/ui/input'; +import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type CommandStructureNode, type IncidentMap, type IncidentNeed, IncidentNeedStatus, type TacticalObjective, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/incidentCommandModels'; @@ -46,10 +49,12 @@ interface LaneDetailsSheetProps { resourceCount?: number; /** Delete the lane; disposition decides what happens to its resources first. */ onDelete?: (commandStructureNodeId: string, disposition: 'pool' | 'release') => void; + /** Start a 1:1 with a lead. Only offered for Resgrid users — external leads have no account to message. */ + onMessageLead?: (userId: string) => void; } /** Edit an existing lane: leads (primary/secondary — Resgrid user or external contact) and linked objectives/need. */ -export const LaneDetailsSheet: React.FC = ({ isOpen, onClose, node, objectives, needs, maps, users, onSave, resourceCount = 0, onDelete }) => { +export const LaneDetailsSheet: React.FC = ({ isOpen, onClose, node, objectives, needs, maps, users, onSave, resourceCount = 0, onDelete, onMessageLead }) => { const { t } = useTranslation(); const [primaryLead, setPrimaryLead] = useState(emptyLead); const [secondaryLead, setSecondaryLead] = useState(emptyLead); @@ -107,7 +112,14 @@ export const LaneDetailsSheet: React.FC = ({ isOpen, onCl const renderLeadEditor = (slot: 'primary' | 'secondary', lead: LeadDraft, setLead: (value: LeadDraft) => void) => ( - {slot === 'primary' ? t('command.primary_lead_label') : t('command.secondary_lead_label')} + + {slot === 'primary' ? t('command.primary_lead_label') : t('command.secondary_lead_label')} + {onMessageLead && lead.userId ? ( + onMessageLead(lead.userId as string)} className="p-2" hitSlop={8} testID={`lane-lead-${slot}-message`}> + + + ) : null} + + {/* Whichever dispatcher is on shift sees this — it is the desk, not a person. */} + {/* A confirmation dialog guards against accidental taps. */} diff --git a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx index dcfa8e0..e001860 100644 --- a/src/hooks/__tests__/use-signalr-lifecycle.test.tsx +++ b/src/hooks/__tests__/use-signalr-lifecycle.test.tsx @@ -17,6 +17,8 @@ describe('useSignalRLifecycle', () => { const mockDisconnectUpdateHub = jest.fn(); const mockConnectGeolocationHub = jest.fn(); const mockDisconnectGeolocationHub = jest.fn(); + const mockConnectChatHub = jest.fn(); + const mockDisconnectChatHub = jest.fn(); // Create shared state for app lifecycle that can be updated let appLifecycleState = { @@ -48,15 +50,21 @@ describe('useSignalRLifecycle', () => { disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, } as any) : { connectUpdateHub: mockConnectUpdateHub, disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, } as any); // Also mock getState for direct store access @@ -65,8 +73,11 @@ describe('useSignalRLifecycle', () => { disconnectUpdateHub: mockDisconnectUpdateHub, connectGeolocationHub: mockConnectGeolocationHub, disconnectGeolocationHub: mockDisconnectGeolocationHub, + connectChatHub: mockConnectChatHub, + disconnectChatHub: mockDisconnectChatHub, isUpdateHubConnected: false, isGeolocationHubConnected: false, + isChatHubConnected: false, }); // Mock useAppLifecycle to return shared state diff --git a/src/lib/__tests__/navigation.test.ts b/src/lib/__tests__/navigation.test.ts index 3395035..29b1465 100644 --- a/src/lib/__tests__/navigation.test.ts +++ b/src/lib/__tests__/navigation.test.ts @@ -2,6 +2,16 @@ import { Platform, Linking } from 'react-native'; import { describe, expect, it, beforeEach, afterEach } from '@jest/globals'; import { openMapsWithDirections, openMapsWithAddress } from '../navigation'; +// Mock expo-router — the real module pulls in its vendored react-navigation tree, which +// needs far more of react-native than the minimal stub below provides. +jest.mock('expo-router', () => ({ + router: { + push: jest.fn(), + replace: jest.fn(), + navigate: jest.fn(), + }, +})); + // Mock React Native modules jest.mock('react-native', () => ({ Platform: { diff --git a/src/services/__tests__/push-notification.test.ts b/src/services/__tests__/push-notification.test.ts index 86720b3..ab7fcba 100644 --- a/src/services/__tests__/push-notification.test.ts +++ b/src/services/__tests__/push-notification.test.ts @@ -23,6 +23,12 @@ jest.mock('react-native', () => ({ }, })); +// Mock the navigation lib — the real module imports expo-router, whose import chain +// needs far more of react-native/expo than the minimal stubs above provide. +jest.mock('@/lib/navigation', () => ({ + routerPushWithRetry: jest.fn().mockResolvedValue(undefined), +})); + jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index b7a8bc3..ef66271 100644 --- a/src/stores/chat/__tests__/hub-invoke-args.test.ts +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -8,6 +8,7 @@ * JoinChannel(string channelId, int? asUnitId) * Typing(string channelId, string displayName, bool isTyping, int? asUnitId) * MarkRead(string channelId, long seq, int? asUnitId) + * SetActiveChannel(string channelId, int? asUnitId) */ const mockInvoke = jest.fn().mockResolvedValue(undefined); @@ -71,6 +72,14 @@ describe('chat hub invocations', () => { expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', null); }); + it('sends both SetActiveChannel arguments, including the null-clear form', () => { + useChatStore.getState().setActiveChannel('channel-1'); + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', null); + + useChatStore.getState().setActiveChannel(null); + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, null); + }); + it('sends all four Typing arguments in hub order', () => { useChatStore.getState().sendTyping('channel-1', true); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 886b200..2650d86 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -283,6 +283,10 @@ export const useChatStore = create()( setActiveChannel: (channelId: string | null) => { set({ activeChannelId: channelId }); + // Hub signature: SetActiveChannel(channelId, asUnitId). A channelId marks the + // conversation as actively viewed (server suppresses push for it); null clears it. + // Both args must be sent — SignalR rejects invocations with omitted optionals. + void safeInvoke('SetActiveChannel', channelId ?? null, null); }, // ------------------------------------------------------------------ @@ -847,6 +851,8 @@ export const useChatStore = create()( void get().drainOutbox(); if (activeChannelId) { void get().joinChannel(activeChannelId); + // Re-assert the active-channel marker; the server forgets it on disconnect. + void safeInvoke('SetActiveChannel', activeChannelId, null); void get().loadNewerMessages(activeChannelId); } }, diff --git a/src/translations/ar.json b/src/translations/ar.json index 5db0af9..5bbdc07 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -357,7 +357,8 @@ "flag_sensitive": "معلومات حساسة", "flag_spam": "رسائل غير مرغوب فيها", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "اسم المجموعة", + "group_name": "اسم المجموعة (اختياري)", + "unit": "الوحدة", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} يكتب الآن...", diff --git a/src/translations/de.json b/src/translations/de.json index 2f59826..fae466d 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -357,7 +357,8 @@ "flag_sensitive": "Sensible Informationen", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Gruppenname", + "group_name": "Gruppenname (optional)", + "unit": "Einheit", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} tippt...", diff --git a/src/translations/en.json b/src/translations/en.json index ff43d9d..83860c3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -357,7 +357,8 @@ "flag_sensitive": "Sensitive information", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Group name", + "group_name": "Group name (optional)", + "unit": "Unit", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} is typing...", diff --git a/src/translations/es.json b/src/translations/es.json index 61ebcef..1bce690 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -357,7 +357,8 @@ "flag_sensitive": "Información confidencial", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Nombre del grupo", + "group_name": "Nombre del grupo (opcional)", + "unit": "Unidad", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} está escribiendo...", diff --git a/src/translations/fr.json b/src/translations/fr.json index 01f157a..6293530 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -357,7 +357,8 @@ "flag_sensitive": "Informations sensibles", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Nom du groupe", + "group_name": "Nom du groupe (facultatif)", + "unit": "Unité", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} est en train d'écrire...", diff --git a/src/translations/it.json b/src/translations/it.json index a5788d5..02962ed 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -357,7 +357,8 @@ "flag_sensitive": "Informazioni sensibili", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Nome del gruppo", + "group_name": "Nome del gruppo (facoltativo)", + "unit": "Unità", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} sta scrivendo...", diff --git a/src/translations/pl.json b/src/translations/pl.json index 2ff7791..69c2b3f 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -357,7 +357,8 @@ "flag_sensitive": "Informacje poufne", "flag_spam": "Spam", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Nazwa grupy", + "group_name": "Nazwa grupy (opcjonalnie)", + "unit": "Jednostka", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} pisze...", diff --git a/src/translations/sv.json b/src/translations/sv.json index 6c66caf..a4ff125 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -357,7 +357,8 @@ "flag_sensitive": "Känslig information", "flag_spam": "Skräppost", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Gruppnamn", + "group_name": "Gruppnamn (valfritt)", + "unit": "Enhet", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} skriver...", diff --git a/src/translations/uk.json b/src/translations/uk.json index 6f0e42f..9ee7aa1 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -357,7 +357,8 @@ "flag_sensitive": "Конфіденційна інформація", "flag_spam": "Спам", "frozen_notice": "This incident is closed. The conversation is kept as a point-in-time record — no new messages or edits, but you can still flag content.", - "group_name": "Назва групи", + "group_name": "Назва групи (необов'язково)", + "unit": "Підрозділ", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} набирає повідомлення...", From a2379cf1cfdaa85a571c223735bc4a95a243ab62 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 20:05:59 -0700 Subject: [PATCH 4/4] RG-T117 PR#38 fixes --- .../chat/__tests__/hub-invoke-args.test.ts | 49 +++++++++++++++++++ src/stores/chat/store.ts | 28 +++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts index ef66271..87d2c32 100644 --- a/src/stores/chat/__tests__/hub-invoke-args.test.ts +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -119,6 +119,55 @@ describe('chat hub invocations', () => { }); }); +describe('active-channel marker resynchronization', () => { + // syncActiveChannelMarker settles on the microtask queue; two ticks drain it. + const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + beforeEach(async () => { + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + useChatStore.getState().reset(); + await flush(); + mockInvoke.mockClear(); + }); + + it('re-asserts a non-null marker on reconnect', async () => { + useChatStore.getState().setActiveChannel('channel-1'); + await flush(); + mockInvoke.mockClear(); + + useChatStore.getState().handleChatConnected(); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', 'channel-1', null); + }); + + it('retries a null marker that failed to send once reconnected', async () => { + mockInvoke.mockRejectedValue(new Error('disconnected')); + useChatStore.getState().setActiveChannel(null); + await flush(); + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + + useChatStore.getState().handleChatConnected(); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'SetActiveChannel', null, null); + }); + + it('does not resend a null marker the hub already confirmed', async () => { + useChatStore.getState().setActiveChannel(null); + await flush(); + mockInvoke.mockClear(); + + useChatStore.getState().handleChatConnected(); + await flush(); + + expect(mockInvoke).not.toHaveBeenCalledWith('chatHub', 'SetActiveChannel', expect.anything(), expect.anything()); + }); +}); + describe('incoming message normalization', () => { beforeEach(() => { useChatStore.setState({ messagesByChannel: {}, channels: [] }); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 2650d86..0f6d1b0 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -233,6 +233,23 @@ async function safeInvoke(method: string, ...args: unknown[]): Promise { } } +/** Active-channel marker the hub has not confirmed yet. Kept — null included, since + * null means "clear the marker" — until an invoke succeeds, so a send that failed + * while offline can be replayed on reconnect. */ +let pendingActiveChannelSync: { channelId: string | null } | null = null; + +async function syncActiveChannelMarker(channelId: string | null): Promise { + const marker = { channelId }; + pendingActiveChannelSync = marker; + try { + await signalRService.invoke(Env.CHAT_HUB_NAME, 'SetActiveChannel', channelId, null); + // Only clear if no newer marker superseded this one while in flight. + if (pendingActiveChannelSync === marker) pendingActiveChannelSync = null; + } catch (error) { + logger.debug({ message: 'chat: invoke SetActiveChannel skipped', context: { error } }); + } +} + export const useChatStore = create()( persist( (set, get) => ({ @@ -286,7 +303,7 @@ export const useChatStore = create()( // Hub signature: SetActiveChannel(channelId, asUnitId). A channelId marks the // conversation as actively viewed (server suppresses push for it); null clears it. // Both args must be sent — SignalR rejects invocations with omitted optionals. - void safeInvoke('SetActiveChannel', channelId ?? null, null); + void syncActiveChannelMarker(channelId ?? null); }, // ------------------------------------------------------------------ @@ -851,10 +868,14 @@ export const useChatStore = create()( void get().drainOutbox(); if (activeChannelId) { void get().joinChannel(activeChannelId); - // Re-assert the active-channel marker; the server forgets it on disconnect. - void safeInvoke('SetActiveChannel', activeChannelId, null); void get().loadNewerMessages(activeChannelId); } + // Re-assert the active-channel marker; the server forgets it on disconnect. + // A pending null (screen closed while offline) is flushed too, so the server + // stops suppressing push for a channel no longer on screen. + if (activeChannelId !== null || pendingActiveChannelSync !== null) { + void syncActiveChannelMarker(activeChannelId); + } }, reset: () => { @@ -863,6 +884,7 @@ export const useChatStore = create()( lastTypingSentAt.clear(); lastMarkedSeq.clear(); pendingChatbotMessages.clear(); + pendingActiveChannelSync = null; clearChatbotTypingTimeout(); if (outboxDrainTimer) { clearTimeout(outboxDrainTimer);