diff --git a/jest-setup.ts b/jest-setup.ts index 5a3032d..03bf010 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1,5 +1,24 @@ import '@testing-library/react-native/extend-expect'; +// Mock react-native-safe-area-context — its source build reads StyleSheet at import time, +// which explodes in suites that stub react-native with a minimal factory (e.g. navigation tests). +jest.mock('react-native-safe-area-context', () => { + const React = require('react'); + + const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children); + + return { + SafeAreaView, + SafeAreaProvider: ({ children }: any) => children, + useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })), + useSafeAreaFrame: jest.fn(() => ({ x: 0, y: 0, width: 375, height: 667 })), + initialWindowMetrics: { + insets: { top: 0, bottom: 0, left: 0, right: 0 }, + frame: { x: 0, y: 0, width: 375, height: 667 }, + }, + }; +}); + // Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest jest.mock('@sentry/react-native', () => ({ captureException: jest.fn(), diff --git a/plugins/__tests__/with-app-icon-badge.test.ts b/plugins/__tests__/with-app-icon-badge.test.ts index 0ae6ed1..31994dd 100644 --- a/plugins/__tests__/with-app-icon-badge.test.ts +++ b/plugins/__tests__/with-app-icon-badge.test.ts @@ -30,6 +30,11 @@ interface TestExpoConfig { const withAppIconBadge = jest.requireActual('../with-app-icon-badge.js') as (config: TestExpoConfig, options?: AppIconBadgeConfig) => TestExpoConfig; +// The plugin builds absolute paths with node:path, so expected values must go through +// path.resolve too — otherwise the assertions only hold on POSIX separators. +const nodePath = jest.requireActual('node:path') as typeof import('node:path'); +const projectPath = (...segments: string[]) => nodePath.resolve('/project', ...segments); + describe('withAppIconBadge', () => { beforeEach(() => { jest.clearAllMocks(); @@ -56,18 +61,18 @@ describe('withAppIconBadge', () => { }; expect(payload.jobs).toEqual([ { - sourcePath: '/project/assets/icon.png', - outputPath: '/project/.expo/app-icon-badge/icon.png', + sourcePath: projectPath('assets/icon.png'), + outputPath: projectPath('.expo/app-icon-badge/icon.png'), isAdaptiveIcon: false, }, { - sourcePath: '/project/assets/ios-icon.png', - outputPath: '/project/.expo/app-icon-badge/ios-icon.png', + sourcePath: projectPath('assets/ios-icon.png'), + outputPath: projectPath('.expo/app-icon-badge/ios-icon.png'), isAdaptiveIcon: false, }, { - sourcePath: '/project/assets/adaptive-icon.png', - outputPath: '/project/.expo/app-icon-badge/foregroundImage.png', + sourcePath: projectPath('assets/adaptive-icon.png'), + outputPath: projectPath('.expo/app-icon-badge/foregroundImage.png'), isAdaptiveIcon: true, }, ]); diff --git a/scripts/__tests__/extract-release-notes.test.ts b/scripts/__tests__/extract-release-notes.test.ts index 27e3afa..9653835 100644 --- a/scripts/__tests__/extract-release-notes.test.ts +++ b/scripts/__tests__/extract-release-notes.test.ts @@ -1,7 +1,9 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; -const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh'); +// Forward slashes so the path survives bash on Windows (Git Bash accepts G:/... paths; +// backslashes would be eaten as escape characters). No-op on POSIX. +const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh').replace(/\\/g, '/'); const extractReleaseNotes = (body: string): string => { const result = spawnSync('bash', [scriptPath, '--extract-only'], { @@ -16,7 +18,11 @@ const extractReleaseNotes = (body: string): string => { return result.stdout.trim(); }; -describe('extract-release-notes', () => { +// Skip on Windows: `bash` resolves to WSL/Git Bash and the checked-out .sh file carries +// CRLF endings there ("set: pipefail: invalid option name"). CI runs this suite on Linux. +const describeOnPosix = process.platform === 'win32' ? describe.skip : describe; + +describeOnPosix('extract-release-notes', () => { it.each(['##', '###'])('normalizes a %s PR Description heading', (heading) => { const notes = extractReleaseNotes(`${heading} PR Description\n\nAdds the release change.`); diff --git a/src/__tests__/security-integration.test.ts b/src/__tests__/security-integration.test.ts index 9d77eb6..6dbea12 100644 --- a/src/__tests__/security-integration.test.ts +++ b/src/__tests__/security-integration.test.ts @@ -26,6 +26,7 @@ describe('Security Permission Logic', () => { CanCreateCalls: true, CanAddNote: false, CanCreateMessage: false, + CanLoginToCommandApp: true, Groups: [] }; @@ -44,6 +45,7 @@ describe('Security Permission Logic', () => { CanCreateCalls: false, CanAddNote: true, CanCreateMessage: true, + CanLoginToCommandApp: true, Groups: [] }; @@ -65,6 +67,7 @@ describe('Security Permission Logic', () => { CanViewPII: true, CanAddNote: true, CanCreateMessage: true, + CanLoginToCommandApp: true, Groups: [] } as unknown as DepartmentRightsResultData; @@ -85,6 +88,7 @@ describe('Security Permission Logic', () => { CanCreateCalls: true, CanAddNote: false, CanCreateMessage: false, + CanLoginToCommandApp: true, Groups: [] }; @@ -107,6 +111,7 @@ describe('Security Permission Logic', () => { CanCreateCalls: false, CanAddNote: true, CanCreateMessage: true, + CanLoginToCommandApp: true, Groups: [] }; 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..da51725 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -39,6 +39,7 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; +import { useToastStore } from '@/stores/toast/store'; import { useWeatherAlertsStore } from '@/stores/weather-alerts/store'; export default function TabLayout() { @@ -175,6 +176,18 @@ export default function TabLayout() { await useCallsStore.getState().init(); await useWeatherAlertsStore.getState().init(); await securityStore.getState().getRights(); + + // The IC app is for commanders. A member the department has not authorized must not get past + // initialization — the server refuses them the board endpoints anyway, so signing them straight + // back out is far clearer than an app that loads and then fails every request. + if (!isCurrentRun()) return; + if (securityStore.getState().rights?.CanLoginToCommandApp === false) { + logger.warn({ message: 'User is not authorized to use the IC app; signing out', context: { userId } }); + useToastStore.getState().showToast('error', t('login.command_not_authorized')); + await useAuthStore.getState().logout(); + return; + } + await featureFlagsStore.getState().fetchFlags(); if (!isCurrentRun()) return; @@ -235,7 +248,7 @@ export default function TabLayout() { setIsInitComplete(true); } } - }, [status]); + }, [status, t, userId]); const refreshDataFromBackground = useCallback(async () => { if (status !== 'signedIn' || !hasInitialized.current) return; @@ -501,24 +514,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 +646,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 +664,8 @@ const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { const CreateHeaderBackButton = () => { return ( { if (router.canGoBack()) { @@ -654,7 +675,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..42d71c2 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -1,8 +1,28 @@ 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, + Radio, + 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 +65,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 +85,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 +178,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 +199,43 @@ 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 dispatchChatChannelId = useMemo(() => incidentChannels?.find((channel) => channel.ChannelType === ChatChannelType.IncidentDispatch)?.ChatChannelId ?? null, [incidentChannels]); + + const handleOpenDispatchChat = useCallback(() => openChatChannel(dispatchChatChannelId, t('command.dispatch_chat_unavailable')), [openChatChannel, dispatchChatChannelId, 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 +583,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. */} - + {/* Whichever dispatcher is on shift sees this — it is the desk, not a person. */} + + {/* A confirmation dialog guards against accidental taps. */} + {/* Quick access to the underlying call's notes/images/files/video without leaving the board */} - - - - - @@ -685,6 +777,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 +791,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 +871,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 +1009,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/chat/new-conversation-sheet.tsx b/src/components/chat/new-conversation-sheet.tsx index 7365d6d..e7c6dd1 100644 --- a/src/components/chat/new-conversation-sheet.tsx +++ b/src/components/chat/new-conversation-sheet.tsx @@ -1,4 +1,4 @@ -import { Check, Search, Users } from 'lucide-react-native'; +import { Check, Search, Truck, Users } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -34,8 +34,15 @@ function recipientUserId(recipient: RecipientsResultData): string { } function isPersonRecipient(recipient: RecipientsResultData): boolean { + // Recipients with an empty Type are the server's pseudo-entries + // ({ Id: "0", Name: "Everyone" } / { Id: "-1", Name: "Nobody" }) — never DM targets. const type = (recipient.Type ?? '').toLowerCase(); - return type === 'personnel' || type === 'person' || type === 'user' || type === 'p' || type === ''; + return type === 'personnel' || type === 'person' || type === 'user' || type === 'p'; +} + +function isUnitRecipient(recipient: RecipientsResultData): boolean { + const type = (recipient.Type ?? '').toLowerCase(); + return type === 'unit' || type === 'units' || type === 'u'; } export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewConversationSheetProps) { @@ -56,10 +63,13 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo setQuery(''); setLoadError(false); setLoading(true); - getRecipients(true, false) + // DM mode also offers units (IC can open a 1:1 with a unit); + // group membership only supports users, so group mode stays people-only. + const includeUnits = mode === 'dm'; + getRecipients(true, includeUnits) .then((result) => { if (cancelled) return; - setRecipients((result.Data ?? []).filter(isPersonRecipient)); + setRecipients((result.Data ?? []).filter((r) => isPersonRecipient(r) || (includeUnits && isUnitRecipient(r)))); }) .catch((error) => { if (cancelled) return; @@ -73,7 +83,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo return () => { cancelled = true; }; - }, [isOpen]); + }, [isOpen, mode]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -94,7 +104,8 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo async (recipient: RecipientsResultData) => { setSubmitting(true); try { - const response = await createDirectMessage({ TargetUserId: recipientUserId(recipient) }); + const targetId = recipientUserId(recipient); + const response = await createDirectMessage(isUnitRecipient(recipient) ? { TargetUnitId: parseInt(targetId, 10) } : { TargetUserId: targetId }); if (response.Data?.ChatChannelId) { onCreated(response.Data.ChatChannelId); onClose(); @@ -110,9 +121,10 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo ); const createGroup = useCallback(async () => { - if (!groupName.trim() || selected.size === 0) return; + if (selected.size === 0) return; setSubmitting(true); try { + // Name may be empty — the server auto-names the group after its members. const response = await createAdHocChannel({ Name: groupName.trim(), MemberUserIds: Array.from(selected) }); if (response.Data?.ChatChannelId) { onCreated(response.Data.ChatChannelId); @@ -167,16 +179,28 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo {filtered.map((recipient) => { const userId = recipientUserId(recipient); const isSelected = selected.has(userId); + const isUnit = isUnitRecipient(recipient); return ( (mode === 'dm' ? startDirectMessage(recipient) : toggle(userId))} disabled={submitting}> - - - + {isUnit ? ( +
+ +
+ ) : ( + + + + )} {recipient.Name} + {isUnit ? ( + + {t('chat.unit')} + + ) : null}
{mode === 'group' && isSelected ? ( @@ -191,7 +215,7 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo )} {mode === 'group' ? ( - 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} +