From 83c0c6f5a9e031542539da81f4383663a5fa5f86 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 22:53:46 -0700 Subject: [PATCH 1/2] RC-T40 IC bug fixes --- src/__tests__/security-integration.test.ts | 95 +++++++++++++++- src/app/(app)/_layout.tsx | 8 ++ src/app/(app)/command.tsx | 14 ++- src/app/chat/[channelId].tsx | 38 +++++-- src/components/ui/bottom-sheet.tsx | 10 +- src/components/ui/side-drawer.tsx | 10 +- .../incident-channels-loading.test.ts | 106 ++++++++++++++++++ src/stores/chat/store.ts | 11 ++ 8 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 src/stores/chat/__tests__/incident-channels-loading.test.ts diff --git a/src/__tests__/security-integration.test.ts b/src/__tests__/security-integration.test.ts index 6dbea12..6a3a4c6 100644 --- a/src/__tests__/security-integration.test.ts +++ b/src/__tests__/security-integration.test.ts @@ -2,7 +2,8 @@ * Security Integration Test * * This test validates that the security permission checking logic works correctly - * for the calls functionality without complex component mocking. + * for the calls functionality, and that an unauthorized member is refused the IC app, + * without complex component mocking. */ import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; @@ -130,4 +131,96 @@ describe('Security Permission Logic', () => { expect(shouldShowMenu).toBe(false); }); }); + + describe('command app authorization gate', () => { + // The layout pulls in Mapbox, Novu, push notifications and the whole store graph, so the + // gate in src/app/(app)/_layout.tsx initializeApp() is exercised through the same shape + // rather than by rendering it. + interface GateEffects { + showToast: jest.Mock; + logout: jest.Mock; + continueInitialization: jest.Mock; + } + + const t = (key: string): string => key; + + const runCommandAppGate = async (rights: DepartmentRightsResultData | null, effects: GateEffects): Promise => { + if (rights?.CanLoginToCommandApp === false) { + effects.showToast('error', t('login.command_not_authorized')); + await effects.logout(); + return; + } + + effects.continueInitialization(); + }; + + const buildRights = (overrides: Partial = {}): DepartmentRightsResultData => ({ + DepartmentName: 'Test Department', + DepartmentCode: 'TEST', + FullName: 'Test User', + EmailAddress: 'test@example.com', + DepartmentId: '1', + IsAdmin: false, + CanViewPII: false, + CanCreateCalls: true, + CanAddNote: false, + CanCreateMessage: false, + CanLoginToCommandApp: true, + Groups: [], + ...overrides, + }); + + let effects: GateEffects; + + beforeEach(() => { + effects = { + showToast: jest.fn(), + logout: jest.fn().mockResolvedValue(undefined), + continueInitialization: jest.fn(), + }; + }); + + it('should toast the localized denial and sign the user out when CanLoginToCommandApp is false', async () => { + await runCommandAppGate(buildRights({ CanLoginToCommandApp: false }), effects); + + expect(effects.showToast).toHaveBeenCalledWith('error', 'login.command_not_authorized'); + expect(effects.logout).toHaveBeenCalledTimes(1); + expect(effects.continueInitialization).not.toHaveBeenCalled(); + }); + + it('should toast before signing out so the reason survives the sign-out navigation', async () => { + await runCommandAppGate(buildRights({ CanLoginToCommandApp: false }), effects); + + expect(effects.showToast.mock.invocationCallOrder[0]).toBeLessThan(effects.logout.mock.invocationCallOrder[0]); + }); + + it('should continue initialization when CanLoginToCommandApp is true', async () => { + await runCommandAppGate(buildRights(), effects); + + expect(effects.showToast).not.toHaveBeenCalled(); + expect(effects.logout).not.toHaveBeenCalled(); + expect(effects.continueInitialization).toHaveBeenCalledTimes(1); + }); + + it('should continue initialization when the server omits CanLoginToCommandApp', async () => { + // The gate is a strict === false check and the model defaults the field to true, so an + // older server that omits it must not lock commanders out of the app. + const rights = buildRights(); + delete (rights as Partial).CanLoginToCommandApp; + + await runCommandAppGate(rights, effects); + + expect(effects.showToast).not.toHaveBeenCalled(); + expect(effects.logout).not.toHaveBeenCalled(); + expect(effects.continueInitialization).toHaveBeenCalledTimes(1); + }); + + it('should continue initialization when rights are not available', async () => { + // A failed rights fetch is not a denial; only an explicit false ends the session. + await runCommandAppGate(null, effects); + + expect(effects.logout).not.toHaveBeenCalled(); + expect(effects.continueInitialization).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index da51725..63cbf2c 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -644,11 +644,15 @@ interface CreateDrawerMenuButtonProps { } const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { + const { t } = useTranslation(); + return ( { setIsOpen(true); }} @@ -662,11 +666,15 @@ const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => { }; const CreateHeaderBackButton = () => { + const { t } = useTranslation(); + return ( { if (router.canGoBack()) { router.back(); diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx index 42d71c2..8c740d5 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -207,6 +207,14 @@ export default function CommandBoard() { const incidentChannels = useChatStore((state) => (boardCallId ? state.incidentChannelsByCallId[boardCallId] : undefined)); + const incidentChannelsLoadFlag = useChatStore((state) => (boardCallId ? state.incidentChannelsLoadingByCallId[boardCallId] : undefined)); + + // The channel map holds undefined both before the fetch lands and for an incident that genuinely + // has no such channel, so a tap mid-load would otherwise claim chat is unavailable. The flag is + // still undefined between the board opening and the load effect firing — treat that as loading + // too, and only fall through to "unavailable" once a request has actually finished. + const isLoadingIncidentChannels = boardCallId ? (incidentChannelsLoadFlag ?? incidentChannels === undefined) : false; + 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]); @@ -214,12 +222,16 @@ export default function CommandBoard() { const openChatChannel = useCallback( (channelId: string | null, unavailableMessage: string) => { if (!channelId) { + // Still fetching: stay silent rather than report a channel missing that may yet arrive. + if (isLoadingIncidentChannels) { + return; + } showToast('info', unavailableMessage); return; } router.push(`/chat/${channelId}`); }, - [showToast] + [showToast, isLoadingIncidentChannels] ); const handleOpenCommandChat = useCallback(() => openChatChannel(commandChatChannelId, t('command.command_chat_unavailable')), [openChatChannel, commandChatChannelId, t]); diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 97be603..5174c7e 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -267,6 +267,32 @@ export default function ChannelConversationScreen() { [channelId, isFrozen] ); + /** + * The actions sheet already hides Edit on a frozen channel, but a channel can freeze while the edit + * sheet is open — the incident closes and SignalR flips IsArchived under it. Drop the in-progress + * edit rather than leave a sheet whose save the server would reject. + */ + useEffect(() => { + if (isFrozen && editMessage) { + setEditMessage(null); + setEditText(''); + useToastStore.getState().showToast('info', t('chat.frozen_notice')); + } + }, [isFrozen, editMessage, t]); + + const handleSaveEdit = useCallback(() => { + // Guards the race between the freeze landing and this press. + if (isFrozen) { + setEditMessage(null); + setEditText(''); + return; + } + if (editMessage && channelId && editText.trim()) { + void useChatStore.getState().editMessage(editMessage.ChatMessageId, channelId, editText.trim()); + } + setEditMessage(null); + }, [isFrozen, editMessage, channelId, editText]); + const openThread = useCallback( (message: ChatMessageResultData) => { router.push(`/chat/thread/${message.ChatMessageId}?channelId=${channelId ?? ''}` as Href); @@ -422,7 +448,7 @@ export default function ChannelConversationScreen() { /> {/* Edit message sheet */} - setEditMessage(null)}> + setEditMessage(null)}> @@ -433,15 +459,7 @@ export default function ChannelConversationScreen() { - diff --git a/src/components/ui/bottom-sheet.tsx b/src/components/ui/bottom-sheet.tsx index 2e6093b..94b8ea0 100644 --- a/src/components/ui/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet.tsx @@ -1,6 +1,6 @@ import { useColorScheme } from 'nativewind'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Animated, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, useWindowDimensions } from 'react-native'; import { Center } from './center'; @@ -48,9 +48,13 @@ export function CustomBottomSheet({ const backdropOpacity = useRef(new Animated.Value(0)).current; // Read inside the close-animation callback, which fires after the animation and would otherwise - // see a stale `isOpen` from the render that started it. + // see a stale `isOpen` from the render that started it. Written in a layout effect rather than + // during render so an abandoned render can't leave the ref describing a state never committed; + // layout effects still run before the passive effect below starts the animation. const isOpenRef = useRef(isOpen); - isOpenRef.current = isOpen; + useLayoutEffect(() => { + isOpenRef.current = isOpen; + }, [isOpen]); // Compute sheet height from first snap point (percentage of screen height). // Clamp between 300px and the screen height to remain usable in all orientations. diff --git a/src/components/ui/side-drawer.tsx b/src/components/ui/side-drawer.tsx index 2d99451..9c60f21 100644 --- a/src/components/ui/side-drawer.tsx +++ b/src/components/ui/side-drawer.tsx @@ -1,6 +1,6 @@ import { useColorScheme } from 'nativewind'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Animated, Modal, Pressable, ScrollView, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -37,9 +37,13 @@ export function SideDrawer({ children, isOpen, onClose, testID }: SideDrawerProp const backdropOpacity = useRef(new Animated.Value(0)).current; // Read inside the close-animation callback, which fires after the animation and would otherwise - // see a stale `isOpen` from the render that started it. + // see a stale `isOpen` from the render that started it. Written in a layout effect rather than + // during render so an abandoned render can't leave the ref describing a state never committed; + // layout effects still run before the passive effect below starts the animation. const isOpenRef = useRef(isOpen); - isOpenRef.current = isOpen; + useLayoutEffect(() => { + isOpenRef.current = isOpen; + }, [isOpen]); useEffect(() => { if (isOpen) { diff --git a/src/stores/chat/__tests__/incident-channels-loading.test.ts b/src/stores/chat/__tests__/incident-channels-loading.test.ts new file mode 100644 index 0000000..aa766fc --- /dev/null +++ b/src/stores/chat/__tests__/incident-channels-loading.test.ts @@ -0,0 +1,106 @@ +/** + * incidentChannelsByCallId holds undefined both before the fetch lands and for an incident that + * genuinely has no channels, so callers (the command board's chat actions) need a separate in-flight + * marker to avoid telling the user chat is unavailable while it is still loading. + */ +const mockGetChannels = jest.fn(); + +jest.mock('@/services/signalr.service', () => ({ + signalRService: { invoke: jest.fn().mockResolvedValue(undefined) }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { CHAT_HUB_NAME: 'chatHub' }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() }, +})); + +jest.mock('@/lib/i18n/utils', () => ({ translate: (key: string) => key })); + +jest.mock('@/lib/storage', () => ({ zustandStorage: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn() } })); + +jest.mock('@/api/chat/chat', () => ({ + getChannels: (...args: unknown[]) => mockGetChannels(...args), + getMessages: jest.fn().mockResolvedValue({ Data: [] }), + getMembers: jest.fn().mockResolvedValue({ Data: [] }), + getMyPendingAcks: jest.fn().mockResolvedValue({ Data: [] }), + markRead: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@/api/chat/chatbot', () => ({ + getChatbotChannel: jest.fn(), + sendChatbotMessage: jest.fn(), + newChatbotSession: jest.fn(), +})); + +jest.mock('@/stores/auth/store', () => ({ + __esModule: true, + default: { getState: () => ({ userId: 'user-1', profile: { name: 'Test User' } }) }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: { getState: () => ({ showToast: jest.fn() }) }, +})); + +// Loaded lazily so the mock factories above run after their `mock*` consts exist. +type ChatStoreApi = typeof import('../store').useChatStore; +let useChatStore: ChatStoreApi; + +beforeAll(() => { + useChatStore = require('../store').useChatStore as ChatStoreApi; +}); + +describe('loadIncidentChannels loading marker', () => { + beforeEach(() => { + mockGetChannels.mockReset(); + useChatStore.setState({ incidentChannelsByCallId: {}, incidentChannelsLoadingByCallId: {} }); + }); + + it('marks the incident as loading until the request resolves', async () => { + let resolveChannels: (value: { Data: unknown[] }) => void = () => undefined; + mockGetChannels.mockReturnValue( + new Promise((resolve) => { + resolveChannels = resolve as (value: { Data: unknown[] }) => void; + }) + ); + + const pending = useChatStore.getState().loadIncidentChannels('42'); + + expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(true); + // The map is still empty mid-flight — the marker is the only way to tell that apart from "none". + expect(useChatStore.getState().incidentChannelsByCallId['42']).toBeUndefined(); + + resolveChannels({ Data: [{ ChatChannelId: 'c-1', CallId: 42 }] }); + await pending; + + expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsByCallId['42']).toHaveLength(1); + }); + + it('records an empty result as loaded so callers can report chat unavailable', async () => { + mockGetChannels.mockResolvedValue({ Data: [{ ChatChannelId: 'c-9', CallId: 99 }] }); + + await useChatStore.getState().loadIncidentChannels('42'); + + expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsByCallId['42']).toEqual([]); + }); + + it('clears the loading marker when the request fails', async () => { + mockGetChannels.mockRejectedValue(new Error('network down')); + + await useChatStore.getState().loadIncidentChannels('42'); + + expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsByCallId['42']).toBeUndefined(); + }); + + it('does not mark loading for an unparseable call id', async () => { + await useChatStore.getState().loadIncidentChannels('not-a-number'); + + expect(mockGetChannels).not.toHaveBeenCalled(); + expect(useChatStore.getState().incidentChannelsLoadingByCallId['not-a-number']).toBeUndefined(); + }); +}); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 0f6d1b0..d82fc0d 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -79,6 +79,12 @@ interface ChatState { * channels: once a command closes its chat stays readable as a point-in-time record. */ incidentChannelsByCallId: Record; + /** + * In-flight marker per incident. Callers need it to tell "channels not loaded yet" from "the + * request finished and this incident genuinely has no such channel" — the map holds undefined + * in both cases. + */ + incidentChannelsLoadingByCallId: Record; loadIncidentChannels: (callId: string) => Promise; setActiveChannel: (channelId: string | null) => void; @@ -271,6 +277,7 @@ export const useChatStore = create()( // Channels // ------------------------------------------------------------------ incidentChannelsByCallId: {}, + incidentChannelsLoadingByCallId: {}, loadIncidentChannels: async (callId: string) => { const numericCallId = parseInt(callId, 10); @@ -278,12 +285,15 @@ export const useChatStore = create()( return; } + set((state) => ({ incidentChannelsLoadingByCallId: { ...state.incidentChannelsLoadingByCallId, [callId]: true } })); try { const response = await chatApi.getChannels(undefined, true); const forCall = (response.Data ?? []).filter((channel) => channel.CallId === numericCallId); set((state) => ({ incidentChannelsByCallId: { ...state.incidentChannelsByCallId, [callId]: forCall } })); } catch (error) { logger.error({ message: 'chat: failed to load incident channels', context: { error, callId } }); + } finally { + set((state) => ({ incidentChannelsLoadingByCallId: { ...state.incidentChannelsLoadingByCallId, [callId]: false } })); } }, @@ -893,6 +903,7 @@ export const useChatStore = create()( set({ channels: [], incidentChannelsByCallId: {}, + incidentChannelsLoadingByCallId: {}, messagesByChannel: {}, membersByChannel: {}, typingByChannel: {}, From 19c11f58fad8ee88c021985281f850e321446789 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 11 Aug 2026 23:11:47 -0700 Subject: [PATCH 2/2] RC-T40 PR#40 fixes --- src/__tests__/security-integration.test.ts | 96 +------------- src/app/(app)/_layout.tsx | 12 +- src/app/(app)/command.tsx | 22 +++- .../auth/__tests__/command-app-access.test.ts | 124 ++++++++++++++++++ src/lib/auth/command-app-access.ts | 31 +++++ .../incident-channels-loading.test.ts | 85 ++++++++++-- src/stores/chat/store.ts | 31 +++-- src/translations/ar.json | 3 +- src/translations/de.json | 3 +- src/translations/en.json | 3 +- src/translations/es.json | 3 +- src/translations/fr.json | 3 +- src/translations/it.json | 3 +- src/translations/pl.json | 3 +- src/translations/sv.json | 3 +- src/translations/uk.json | 3 +- 16 files changed, 292 insertions(+), 136 deletions(-) create mode 100644 src/lib/auth/__tests__/command-app-access.test.ts create mode 100644 src/lib/auth/command-app-access.ts diff --git a/src/__tests__/security-integration.test.ts b/src/__tests__/security-integration.test.ts index 6a3a4c6..9957e78 100644 --- a/src/__tests__/security-integration.test.ts +++ b/src/__tests__/security-integration.test.ts @@ -2,8 +2,7 @@ * Security Integration Test * * This test validates that the security permission checking logic works correctly - * for the calls functionality, and that an unauthorized member is refused the IC app, - * without complex component mocking. + * for the calls functionality without complex component mocking. */ import { type DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; @@ -132,95 +131,6 @@ describe('Security Permission Logic', () => { }); }); - describe('command app authorization gate', () => { - // The layout pulls in Mapbox, Novu, push notifications and the whole store graph, so the - // gate in src/app/(app)/_layout.tsx initializeApp() is exercised through the same shape - // rather than by rendering it. - interface GateEffects { - showToast: jest.Mock; - logout: jest.Mock; - continueInitialization: jest.Mock; - } - - const t = (key: string): string => key; - - const runCommandAppGate = async (rights: DepartmentRightsResultData | null, effects: GateEffects): Promise => { - if (rights?.CanLoginToCommandApp === false) { - effects.showToast('error', t('login.command_not_authorized')); - await effects.logout(); - return; - } - - effects.continueInitialization(); - }; - - const buildRights = (overrides: Partial = {}): DepartmentRightsResultData => ({ - DepartmentName: 'Test Department', - DepartmentCode: 'TEST', - FullName: 'Test User', - EmailAddress: 'test@example.com', - DepartmentId: '1', - IsAdmin: false, - CanViewPII: false, - CanCreateCalls: true, - CanAddNote: false, - CanCreateMessage: false, - CanLoginToCommandApp: true, - Groups: [], - ...overrides, - }); - - let effects: GateEffects; - - beforeEach(() => { - effects = { - showToast: jest.fn(), - logout: jest.fn().mockResolvedValue(undefined), - continueInitialization: jest.fn(), - }; - }); - - it('should toast the localized denial and sign the user out when CanLoginToCommandApp is false', async () => { - await runCommandAppGate(buildRights({ CanLoginToCommandApp: false }), effects); - - expect(effects.showToast).toHaveBeenCalledWith('error', 'login.command_not_authorized'); - expect(effects.logout).toHaveBeenCalledTimes(1); - expect(effects.continueInitialization).not.toHaveBeenCalled(); - }); - - it('should toast before signing out so the reason survives the sign-out navigation', async () => { - await runCommandAppGate(buildRights({ CanLoginToCommandApp: false }), effects); - - expect(effects.showToast.mock.invocationCallOrder[0]).toBeLessThan(effects.logout.mock.invocationCallOrder[0]); - }); - - it('should continue initialization when CanLoginToCommandApp is true', async () => { - await runCommandAppGate(buildRights(), effects); - - expect(effects.showToast).not.toHaveBeenCalled(); - expect(effects.logout).not.toHaveBeenCalled(); - expect(effects.continueInitialization).toHaveBeenCalledTimes(1); - }); - - it('should continue initialization when the server omits CanLoginToCommandApp', async () => { - // The gate is a strict === false check and the model defaults the field to true, so an - // older server that omits it must not lock commanders out of the app. - const rights = buildRights(); - delete (rights as Partial).CanLoginToCommandApp; - - await runCommandAppGate(rights, effects); - - expect(effects.showToast).not.toHaveBeenCalled(); - expect(effects.logout).not.toHaveBeenCalled(); - expect(effects.continueInitialization).toHaveBeenCalledTimes(1); - }); - - it('should continue initialization when rights are not available', async () => { - // A failed rights fetch is not a denial; only an explicit false ends the session. - await runCommandAppGate(null, effects); - - expect(effects.logout).not.toHaveBeenCalled(); - expect(effects.continueInitialization).toHaveBeenCalledTimes(1); - }); - }); + // The IC authorization gate (CanLoginToCommandApp) is production code in + // src/lib/auth/command-app-access.ts and is covered by its own test suite. }); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 63cbf2c..edb1b57 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -25,6 +25,7 @@ import { useAppLifecycle } from '@/hooks/use-app-lifecycle'; import { useSignalRLifecycle } from '@/hooks/use-signalr-lifecycle'; import { getAppHeaderHeight } from '@/lib/app-shell-layout'; import { useAuthStore } from '@/lib/auth'; +import { enforceCommandAppAccess } from '@/lib/auth/command-app-access'; import { logger } from '@/lib/logging'; import { getMapsHeaderState } from '@/lib/maps-route'; import { useIsFirstTime } from '@/lib/storage'; @@ -39,7 +40,6 @@ 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() { @@ -177,14 +177,10 @@ export default function TabLayout() { 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. + // An unauthorized member is toasted and signed out here rather than left in an app that would + // fail every board request; see enforceCommandAppAccess. 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(); + if (await enforceCommandAppAccess({ deniedMessage: t('login.command_not_authorized'), userId })) { return; } diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx index 8c740d5..a11bd89 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -207,13 +207,14 @@ export default function CommandBoard() { const incidentChannels = useChatStore((state) => (boardCallId ? state.incidentChannelsByCallId[boardCallId] : undefined)); - const incidentChannelsLoadFlag = useChatStore((state) => (boardCallId ? state.incidentChannelsLoadingByCallId[boardCallId] : undefined)); + const incidentChannelsStatus = useChatStore((state) => (boardCallId ? state.incidentChannelsStatusByCallId[boardCallId] : undefined)); - // The channel map holds undefined both before the fetch lands and for an incident that genuinely - // has no such channel, so a tap mid-load would otherwise claim chat is unavailable. The flag is - // still undefined between the board opening and the load effect firing — treat that as loading - // too, and only fall through to "unavailable" once a request has actually finished. - const isLoadingIncidentChannels = boardCallId ? (incidentChannelsLoadFlag ?? incidentChannels === undefined) : false; + // The channel map holds undefined mid-fetch, after a failure, and for an incident that genuinely + // has no such channel, so a tap would otherwise claim chat is unavailable in all three cases. The + // status is still absent between the board opening and the load effect firing — treat that as + // loading too, so only a completed request can produce a message. + const isLoadingIncidentChannels = boardCallId ? (incidentChannelsStatus ?? 'loading') === 'loading' : false; + const didIncidentChannelsFail = incidentChannelsStatus === 'failed'; const commandChatChannelId = useMemo(() => incidentChannels?.find((channel) => channel.ChannelType === ChatChannelType.IncidentCommand)?.ChatChannelId ?? null, [incidentChannels]); @@ -226,12 +227,19 @@ export default function CommandBoard() { if (isLoadingIncidentChannels) { return; } + // The channel list never arrived, so nothing is known about this incident's chat — say the + // load failed and let the commander retry instead of declaring the channel missing. + if (didIncidentChannelsFail) { + showToast('error', t('command.chat_load_failed')); + void useChatStore.getState().loadIncidentChannels(boardCallId ?? ''); + return; + } showToast('info', unavailableMessage); return; } router.push(`/chat/${channelId}`); }, - [showToast, isLoadingIncidentChannels] + [showToast, isLoadingIncidentChannels, didIncidentChannelsFail, boardCallId, t] ); const handleOpenCommandChat = useCallback(() => openChatChannel(commandChatChannelId, t('command.command_chat_unavailable')), [openChatChannel, commandChatChannelId, t]); diff --git a/src/lib/auth/__tests__/command-app-access.test.ts b/src/lib/auth/__tests__/command-app-access.test.ts new file mode 100644 index 0000000..c85e735 --- /dev/null +++ b/src/lib/auth/__tests__/command-app-access.test.ts @@ -0,0 +1,124 @@ +/** + * The IC app is commander-only. These cover the gate initializeApp() runs after fetching rights: + * an unauthorized member is told why and signed out, and every other rights state — including a + * server that omits the field or a fetch that failed — leaves the session alone. + */ +import { enforceCommandAppAccess } from '../command-app-access'; + +import type { DepartmentRightsResultData } from '@/models/v4/security/departmentRightsResultData'; + +const mockLogout = jest.fn(); +const mockShowToast = jest.fn(); +let mockRights: DepartmentRightsResultData | null = null; + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/stores/auth/store', () => ({ + __esModule: true, + default: { getState: () => ({ logout: mockLogout }) }, +})); + +jest.mock('@/stores/security/store', () => ({ + securityStore: { getState: () => ({ rights: mockRights }) }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: { getState: () => ({ showToast: mockShowToast }) }, +})); + +const buildRights = (overrides: Partial = {}): DepartmentRightsResultData => + ({ + DepartmentName: 'Test Department', + DepartmentCode: 'TEST', + FullName: 'Test User', + EmailAddress: 'test@example.com', + DepartmentId: '1', + IsAdmin: false, + CanViewPII: false, + CanCreateCalls: true, + CanAddNote: false, + CanCreateMessage: false, + CanLoginToCommandApp: true, + Groups: [], + ...overrides, + }) as DepartmentRightsResultData; + +describe('enforceCommandAppAccess', () => { + beforeEach(() => { + mockLogout.mockReset().mockResolvedValue(undefined); + mockShowToast.mockReset(); + mockRights = null; + }); + + it('toasts the denial and signs the user out when CanLoginToCommandApp is false', async () => { + mockRights = buildRights({ CanLoginToCommandApp: false }); + + const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized', userId: 'user-1' }); + + // Returning true is how the caller knows to abandon the rest of initialization. + expect(denied).toBe(true); + expect(mockShowToast).toHaveBeenCalledWith('error', 'Not authorized'); + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + + it('toasts before signing out so the reason survives the sign-out navigation', async () => { + mockRights = buildRights({ CanLoginToCommandApp: false }); + + await enforceCommandAppAccess({ deniedMessage: 'Not authorized' }); + + expect(mockShowToast.mock.invocationCallOrder[0]).toBeLessThan(mockLogout.mock.invocationCallOrder[0]); + }); + + it('waits for the sign-out to finish before reporting the denial', async () => { + mockRights = buildRights({ CanLoginToCommandApp: false }); + let logoutFinished = false; + mockLogout.mockImplementation( + () => + new Promise((resolve) => { + setImmediate(() => { + logoutFinished = true; + resolve(); + }); + }) + ); + + await enforceCommandAppAccess({ deniedMessage: 'Not authorized' }); + + expect(logoutFinished).toBe(true); + }); + + it('allows initialization to continue when CanLoginToCommandApp is true', async () => { + mockRights = buildRights(); + + const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' }); + + expect(denied).toBe(false); + expect(mockShowToast).not.toHaveBeenCalled(); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('allows initialization to continue when the server omits CanLoginToCommandApp', async () => { + // The check is a strict === false and the model defaults the field to true, so an older + // server that omits it must not lock commanders out. + const rights = buildRights(); + delete (rights as Partial).CanLoginToCommandApp; + mockRights = rights; + + const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' }); + + expect(denied).toBe(false); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('allows initialization to continue when rights are not available', async () => { + // A failed rights fetch is not a denial; only an explicit false ends the session. + mockRights = null; + + const denied = await enforceCommandAppAccess({ deniedMessage: 'Not authorized' }); + + expect(denied).toBe(false); + expect(mockLogout).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/auth/command-app-access.ts b/src/lib/auth/command-app-access.ts new file mode 100644 index 0000000..ecc845d --- /dev/null +++ b/src/lib/auth/command-app-access.ts @@ -0,0 +1,31 @@ +import { logger } from '@/lib/logging'; +import useAuthStore from '@/stores/auth/store'; +import { securityStore } from '@/stores/security/store'; +import { useToastStore } from '@/stores/toast/store'; + +interface EnforceCommandAppAccessArgs { + /** Already-localized denial text; the caller owns translation so language changes take effect. */ + deniedMessage: string; + userId?: string | null; +} + +/** + * 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. + * + * The check is deliberately strict: only an explicit false denies. Rights that failed to load, or a + * server old enough to omit the field, leave the session alone rather than locking a commander out. + * + * @returns true when the user was denied and signed out — the caller must stop initializing. + */ +export async function enforceCommandAppAccess({ deniedMessage, userId }: EnforceCommandAppAccessArgs): Promise { + if (securityStore.getState().rights?.CanLoginToCommandApp !== false) { + return false; + } + + logger.warn({ message: 'User is not authorized to use the IC app; signing out', context: { userId } }); + useToastStore.getState().showToast('error', deniedMessage); + await useAuthStore.getState().logout(); + return true; +} diff --git a/src/stores/chat/__tests__/incident-channels-loading.test.ts b/src/stores/chat/__tests__/incident-channels-loading.test.ts index aa766fc..1b694af 100644 --- a/src/stores/chat/__tests__/incident-channels-loading.test.ts +++ b/src/stores/chat/__tests__/incident-channels-loading.test.ts @@ -52,10 +52,10 @@ beforeAll(() => { useChatStore = require('../store').useChatStore as ChatStoreApi; }); -describe('loadIncidentChannels loading marker', () => { +describe('loadIncidentChannels status', () => { beforeEach(() => { mockGetChannels.mockReset(); - useChatStore.setState({ incidentChannelsByCallId: {}, incidentChannelsLoadingByCallId: {} }); + useChatStore.setState({ incidentChannelsByCallId: {}, incidentChannelsStatusByCallId: {} }); }); it('marks the incident as loading until the request resolves', async () => { @@ -68,39 +68,104 @@ describe('loadIncidentChannels loading marker', () => { const pending = useChatStore.getState().loadIncidentChannels('42'); - expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(true); - // The map is still empty mid-flight — the marker is the only way to tell that apart from "none". + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loading'); + // The map is still empty mid-flight — the status is the only way to tell that apart from "none". expect(useChatStore.getState().incidentChannelsByCallId['42']).toBeUndefined(); resolveChannels({ Data: [{ ChatChannelId: 'c-1', CallId: 42 }] }); await pending; - expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loaded'); expect(useChatStore.getState().incidentChannelsByCallId['42']).toHaveLength(1); }); it('records an empty result as loaded so callers can report chat unavailable', async () => { + // The incident has no channels of its own: the request succeeded, the filter matched nothing. mockGetChannels.mockResolvedValue({ Data: [{ ChatChannelId: 'c-9', CallId: 99 }] }); await useChatStore.getState().loadIncidentChannels('42'); - expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loaded'); expect(useChatStore.getState().incidentChannelsByCallId['42']).toEqual([]); }); - it('clears the loading marker when the request fails', async () => { + it('marks the incident failed — not loaded — when the request throws', async () => { mockGetChannels.mockRejectedValue(new Error('network down')); await useChatStore.getState().loadIncidentChannels('42'); - expect(useChatStore.getState().incidentChannelsLoadingByCallId['42']).toBe(false); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('failed'); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).not.toBe('loaded'); + // Nothing was written, so an empty map here must not read as "this incident has no chat". expect(useChatStore.getState().incidentChannelsByCallId['42']).toBeUndefined(); }); - it('does not mark loading for an unparseable call id', async () => { + it('recovers to loaded when a retry succeeds after a failure', async () => { + mockGetChannels.mockRejectedValueOnce(new Error('network down')); + await useChatStore.getState().loadIncidentChannels('42'); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('failed'); + + mockGetChannels.mockResolvedValue({ Data: [{ ChatChannelId: 'c-1', CallId: 42 }] }); + await useChatStore.getState().loadIncidentChannels('42'); + + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loaded'); + expect(useChatStore.getState().incidentChannelsByCallId['42']).toHaveLength(1); + }); + + it('runs one request per incident while a fetch is already open', async () => { + let resolveChannels: (value: { Data: unknown[] }) => void = () => undefined; + mockGetChannels.mockReturnValue( + new Promise((resolve) => { + resolveChannels = resolve as (value: { Data: unknown[] }) => void; + }) + ); + + // The board's load effect and a retry tap both firing while the first fetch is open. + const first = useChatStore.getState().loadIncidentChannels('42'); + const second = useChatStore.getState().loadIncidentChannels('42'); + + // The second call is a no-op that resolves immediately; the first is still in flight. + await second; + expect(mockGetChannels).toHaveBeenCalledTimes(1); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loading'); + + resolveChannels({ Data: [{ ChatChannelId: 'c-1', CallId: 42 }] }); + await first; + + expect(mockGetChannels).toHaveBeenCalledTimes(1); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loaded'); + expect(useChatStore.getState().incidentChannelsByCallId['42']).toHaveLength(1); + }); + + it('lets a different incident load while one is in flight', async () => { + const resolvers: ((value: { Data: unknown[] }) => void)[] = []; + mockGetChannels.mockImplementation(() => new Promise((resolve) => resolvers.push(resolve as (value: { Data: unknown[] }) => void))); + + const first = useChatStore.getState().loadIncidentChannels('42'); + const second = useChatStore.getState().loadIncidentChannels('43'); + + // The guard is per call id — a second incident must not be blocked by the first. + expect(mockGetChannels).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().incidentChannelsStatusByCallId['43']).toBe('loading'); + + resolvers.forEach((resolve) => resolve({ Data: [] })); + await Promise.all([first, second]); + }); + + it('allows a fresh request once the previous one settled', async () => { + mockGetChannels.mockResolvedValue({ Data: [{ ChatChannelId: 'c-1', CallId: 42 }] }); + + await useChatStore.getState().loadIncidentChannels('42'); + await useChatStore.getState().loadIncidentChannels('42'); + + expect(mockGetChannels).toHaveBeenCalledTimes(2); + expect(useChatStore.getState().incidentChannelsStatusByCallId['42']).toBe('loaded'); + }); + + it('does not set a status for an unparseable call id', async () => { await useChatStore.getState().loadIncidentChannels('not-a-number'); expect(mockGetChannels).not.toHaveBeenCalled(); - expect(useChatStore.getState().incidentChannelsLoadingByCallId['not-a-number']).toBeUndefined(); + expect(useChatStore.getState().incidentChannelsStatusByCallId['not-a-number']).toBeUndefined(); }); }); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index d82fc0d..87d01c8 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -33,6 +33,9 @@ const getTranslatedMessage = (key: Parameters[0], fallback: st return typeof message === 'string' && message.length > 0 && message !== key ? message : fallback; }; +/** Request state for one incident's chat channels; absent from the map means never requested. */ +export type IncidentChannelsStatus = 'loading' | 'loaded' | 'failed'; + export interface ChatTypingUser { userId: string; displayName?: string; @@ -80,11 +83,11 @@ interface ChatState { */ incidentChannelsByCallId: Record; /** - * In-flight marker per incident. Callers need it to tell "channels not loaded yet" from "the - * request finished and this incident genuinely has no such channel" — the map holds undefined - * in both cases. + * Per-incident request status. The channel map holds undefined for "still loading", "the request + * failed" and "this incident genuinely has no such channel" alike, so callers need this to tell + * a retryable failure from a channel that really does not exist. Absent = never requested. */ - incidentChannelsLoadingByCallId: Record; + incidentChannelsStatusByCallId: Record; loadIncidentChannels: (callId: string) => Promise; setActiveChannel: (channelId: string | null) => void; @@ -277,7 +280,7 @@ export const useChatStore = create()( // Channels // ------------------------------------------------------------------ incidentChannelsByCallId: {}, - incidentChannelsLoadingByCallId: {}, + incidentChannelsStatusByCallId: {}, loadIncidentChannels: async (callId: string) => { const numericCallId = parseInt(callId, 10); @@ -285,15 +288,25 @@ export const useChatStore = create()( return; } - set((state) => ({ incidentChannelsLoadingByCallId: { ...state.incidentChannelsLoadingByCallId, [callId]: true } })); + // One request per incident at a time. The board's load effect and a retry tap can both fire + // while a fetch is open, and the extra round trips only race the same result into the store. + if (get().incidentChannelsStatusByCallId[callId] === 'loading') { + return; + } + + const setStatus = (status: IncidentChannelsStatus) => set((state) => ({ incidentChannelsStatusByCallId: { ...state.incidentChannelsStatusByCallId, [callId]: status } })); + + setStatus('loading'); try { const response = await chatApi.getChannels(undefined, true); const forCall = (response.Data ?? []).filter((channel) => channel.CallId === numericCallId); set((state) => ({ incidentChannelsByCallId: { ...state.incidentChannelsByCallId, [callId]: forCall } })); + setStatus('loaded'); } catch (error) { logger.error({ message: 'chat: failed to load incident channels', context: { error, callId } }); - } finally { - set((state) => ({ incidentChannelsLoadingByCallId: { ...state.incidentChannelsLoadingByCallId, [callId]: false } })); + // Failed, not loaded: an empty channel map here means the request never landed, so callers + // must offer a retry rather than report the incident has no chat. + setStatus('failed'); } }, @@ -903,7 +916,7 @@ export const useChatStore = create()( set({ channels: [], incidentChannelsByCallId: {}, - incidentChannelsLoadingByCallId: {}, + incidentChannelsStatusByCallId: {}, messagesByChannel: {}, membersByChannel: {}, typingByChannel: {}, diff --git a/src/translations/ar.json b/src/translations/ar.json index 5bbdc07..94b7f80 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -358,7 +358,6 @@ "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": "اسم المجموعة (اختياري)", - "unit": "الوحدة", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} يكتب الآن...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} ردود", "title": "الدردشة", "type_a_message": "اكتب رسالة", + "unit": "الوحدة", "unpin": "إلغاء التثبيت", "urgent": "عاجل", "urgent_will_send": "سيتم إرسال هذه الرسالة كرسالة عاجلة" @@ -475,6 +475,7 @@ "channel_name_placeholder": "مثال: تكتيكي 1", "channel_preset_command": "القيادة", "channel_preset_tactical": "تكتيكي", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "إغلاق الكل", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/de.json b/src/translations/de.json index fae466d..a756b3e 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -358,7 +358,6 @@ "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 (optional)", - "unit": "Einheit", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} tippt...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} Antworten", "title": "Chat", "type_a_message": "Nachricht eingeben", + "unit": "Einheit", "unpin": "Anheften aufheben", "urgent": "Dringend", "urgent_will_send": "Diese Nachricht wird als dringend gesendet" @@ -475,6 +475,7 @@ "channel_name_placeholder": "z. B. Taktik 1", "channel_preset_command": "Führung", "channel_preset_tactical": "Taktik", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Alle schließen", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/en.json b/src/translations/en.json index 83860c3..a221a39 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -358,7 +358,6 @@ "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 (optional)", - "unit": "Unit", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} is typing...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} replies", "title": "Chat", "type_a_message": "Type a message", + "unit": "Unit", "unpin": "Unpin", "urgent": "Urgent", "urgent_will_send": "This message will be sent as urgent" @@ -475,6 +475,7 @@ "channel_name_placeholder": "e.g. Tactical 1", "channel_preset_command": "Command", "channel_preset_tactical": "Tactical", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Close All", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/es.json b/src/translations/es.json index 1bce690..c497912 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -358,7 +358,6 @@ "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 (opcional)", - "unit": "Unidad", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} está escribiendo...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} respuestas", "title": "Chat", "type_a_message": "Escribe un mensaje", + "unit": "Unidad", "unpin": "Desfijar", "urgent": "Urgente", "urgent_will_send": "Este mensaje se enviará como urgente" @@ -475,6 +475,7 @@ "channel_name_placeholder": "p. ej. Táctico 1", "channel_preset_command": "Mando", "channel_preset_tactical": "Táctico", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Cerrar todo", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/fr.json b/src/translations/fr.json index 6293530..68e0af7 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -358,7 +358,6 @@ "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 (facultatif)", - "unit": "Unité", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} est en train d'écrire...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} réponses", "title": "Chat", "type_a_message": "Saisir un message", + "unit": "Unité", "unpin": "Désépingler", "urgent": "Urgent", "urgent_will_send": "Ce message sera envoyé comme urgent" @@ -475,6 +475,7 @@ "channel_name_placeholder": "ex. Tactique 1", "channel_preset_command": "Commandement", "channel_preset_tactical": "Tactique", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Tout fermer", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/it.json b/src/translations/it.json index 02962ed..e03ddac 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -358,7 +358,6 @@ "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 (facoltativo)", - "unit": "Unità", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} sta scrivendo...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} risposte", "title": "Chat", "type_a_message": "Scrivi un messaggio", + "unit": "Unità", "unpin": "Rimuovi fissaggio", "urgent": "Urgente", "urgent_will_send": "Questo messaggio verrà inviato come urgente" @@ -475,6 +475,7 @@ "channel_name_placeholder": "es. Tattico 1", "channel_preset_command": "Comando", "channel_preset_tactical": "Tattico", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Chiudi tutto", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/pl.json b/src/translations/pl.json index 69c2b3f..5313952 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -358,7 +358,6 @@ "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 (opcjonalnie)", - "unit": "Jednostka", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} pisze...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} odpowiedzi", "title": "Czat", "type_a_message": "Napisz wiadomość", + "unit": "Jednostka", "unpin": "Odepnij", "urgent": "Pilne", "urgent_will_send": "Ta wiadomość zostanie wysłana jako pilna" @@ -475,6 +475,7 @@ "channel_name_placeholder": "np. Taktyczny 1", "channel_preset_command": "Dowodzenie", "channel_preset_tactical": "Taktyczny", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Zamknij wszystkie", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/sv.json b/src/translations/sv.json index a4ff125..5558012 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -358,7 +358,6 @@ "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 (valfritt)", - "unit": "Enhet", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} skriver...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} svar", "title": "Chatt", "type_a_message": "Skriv ett meddelande", + "unit": "Enhet", "unpin": "Lossa", "urgent": "Brådskande", "urgent_will_send": "Det här meddelandet skickas som brådskande" @@ -475,6 +475,7 @@ "channel_name_placeholder": "t.ex. Taktisk 1", "channel_preset_command": "Ledning", "channel_preset_tactical": "Taktisk", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Stäng alla", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.", diff --git a/src/translations/uk.json b/src/translations/uk.json index 9ee7aa1..0093b02 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -358,7 +358,6 @@ "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": "Назва групи (необов'язково)", - "unit": "Підрозділ", "incident_command_channel": "Command chat", "incident_lane_channel": "Lane chat", "is_typing": "{{name}} набирає повідомлення...", @@ -397,6 +396,7 @@ "thread_replies": "{{count}} відповідей", "title": "Чат", "type_a_message": "Введіть повідомлення", + "unit": "Підрозділ", "unpin": "Відкріпити", "urgent": "Терміново", "urgent_will_send": "Це повідомлення буде надіслано як термінове" @@ -475,6 +475,7 @@ "channel_name_placeholder": "напр. Тактичний 1", "channel_preset_command": "Командування", "channel_preset_tactical": "Тактичний", + "chat_load_failed": "Couldn't load this incident's chat channels. Tap again to retry.", "close_channels": "Закрити всі", "command_chat": "Command chat", "command_chat_unavailable": "No command chat channel has been created for this incident yet.",