Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { api } from '../common/client';

const FEATURE_TOGGLES = '/FeatureToggles';

// ---------------------------------------------------------------------------
// Feature toggle evaluation (department-scoped, any authenticated user).
// Backed by the v4 FeatureToggles API; keys live in Resgrid.Model.FeatureFlagKeys.
// ---------------------------------------------------------------------------

export interface FeatureToggleData {
Key: string;
Enabled: boolean;
Value?: string | null;
ValueType?: string | null;
Source?: string | null;
}

export interface FeatureTogglesResult {
Data?: FeatureToggleData[];
StateHash?: string;
}

export interface FeatureToggleResult {
Data?: FeatureToggleData;
}

/** Evaluates every active flag for the caller's department. */
export const getAllFeatureFlags = async (signal?: AbortSignal) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Incomplete JSDoc: getAllFeatureFlags (line 27) omits @returns {Promise<FeatureTogglesResult>} and rejection conditions, leaving callers without knowledge of the resolved type, rejection scenarios, or that it must be used with await. Add @returns {Promise<FeatureTogglesResult>} and document rejection scenarios (e.g., network failure, auth error).

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 28:

Incomplete JSDoc: `getAllFeatureFlags` (line 27) omits `@returns {Promise<FeatureTogglesResult>}` and rejection conditions, leaving callers without knowledge of the resolved type, rejection scenarios, or that it must be used with `await`. Add `@returns {Promise<FeatureTogglesResult>}` and document rejection scenarios (e.g., network failure, auth error).

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const response = await api.get<FeatureTogglesResult>(`${FEATURE_TOGGLES}/GetAll`, { signal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled promise rejection: the awaited api.get call at src/api/feature-flags/feature-flags.ts:35 propagates to the caller with no structured context on network errors or non-2xx responses. Wrap the call in try { ... } catch (e) { /* log with context or rethrow mapped error */ }.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 29:

Unhandled promise rejection: the awaited `api.get` call at `src/api/feature-flags/feature-flags.ts:35` propagates to the caller with no structured context on network errors or non-2xx responses. Wrap the call in `try { ... } catch (e) { /* log with context or rethrow mapped error */ }`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled exception: the external HTTP call at src/api/feature-flags/feature-flags.ts:35 is not wrapped in try/catch, so network/file/external failures propagate unhandled with no context or application-level error mapping. Wrap the call in try { ... } catch (e) { /* add context (endpoint, operation) and map to app error */ }.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 29:

Unhandled exception: the external HTTP call at `src/api/feature-flags/feature-flags.ts:35` is not wrapped in try/catch, so network/file/external failures propagate unhandled with no context or application-level error mapping. Wrap the call in `try { ... } catch (e) { /* add context (endpoint, operation) and map to app error */ }`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return response.data;
};

/** Lightweight enabled-only check for a single flag. */
export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => {
const response = await api.get<FeatureToggleResult>(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal });
return response.data;
};
16 changes: 11 additions & 5 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { usePushNotifications } from '@/services/push-notification';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
import { useCommandStore } from '@/stores/command/store';
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';
Expand Down Expand Up @@ -168,16 +169,21 @@ export default function TabLayout() {
await useCallsStore.getState().init();
await useWeatherAlertsStore.getState().init();
await securityStore.getState().getRights();
await featureFlagsStore.getState().fetchFlags();

await useSignalRStore.getState().connectUpdateHub();
await useSignalRStore.getState().connectGeolocationHub();

// Connect the realtime chat hub (best-effort; chat may be disabled per department)
try {
await useSignalRStore.getState().connectChatHub();
logger.info({ message: 'SignalR chat hub connected successfully' });
} catch (chatError) {
logger.warn({ message: 'Failed to connect SignalR chat hub during initialization', context: { error: chatError } });
if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
try {
await useSignalRStore.getState().connectChatHub();
logger.info({ message: 'SignalR chat hub connected successfully' });
} catch (chatError) {
logger.warn({ message: 'Failed to connect SignalR chat hub during initialization', context: { error: chatError } });
}
} else {
logger.info({ message: 'Chat disabled by feature flag; skipping chat hub connection' });
}

// Hydrate incident-command boards from the server Sync Bundle (best-effort; offline-safe)
Expand Down
25 changes: 23 additions & 2 deletions src/app/(app)/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router';
import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router';
import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -15,10 +15,12 @@ import { Fab, FabIcon } from '@/components/ui/fab';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { HStack } from '@/components/ui/hstack';
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 ChatChannelResultData, ChatChannelType } from '@/models/v4/chat';
import { useChatStore } from '@/stores/chat/store';
import { useChatSystemStatus } from '@/stores/feature-flags/store';

function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) {
const { t } = useTranslation();
Expand Down Expand Up @@ -86,12 +88,15 @@ export default function ChatScreen() {
const pendingAcks = useChatStore((s) => s.pendingAcks);
const [fabOpen, setFabOpen] = useState(false);
const [newMode, setNewMode] = useState<'dm' | 'group' | null>(null);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Hardcoded string literal 'enabled' represents a finite set of chat system statuses and risks typos. Export named constants like CHAT_STATUS from the feature-flags store and compare against CHAT_STATUS.ENABLED.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/app/(app)/chat.tsx:

Line 92:

Hardcoded string literal 'enabled' represents a finite set of chat system statuses and risks typos. Export named constants like `CHAT_STATUS` from the feature-flags store and compare against `CHAT_STATUS.ENABLED`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
useChatStore.getState().fetchChannels();
useChatStore.getState().fetchPendingAcks();
}, [])
}, [isChatEnabled])
);

const grouped = groupChannels(channels);
Expand All @@ -103,6 +108,22 @@ export default function ChatScreen() {
[router]
);

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: no chat for this department.
if (chatStatus === 'disabled') {
return <Redirect href="/" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
Expand Down
28 changes: 25 additions & 3 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stack, useFocusEffect } from 'expo-router';
import { Redirect, Stack, useFocusEffect } from 'expo-router';
import { RefreshCw, Send, Sparkles } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -13,11 +13,13 @@ import { HStack } from '@/components/ui/hstack';
import { Input, InputField } from '@/components/ui/input';
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';
import { useChatSystemStatus } from '@/stores/feature-flags/store';

export default function ChatbotScreen() {
const { t } = useTranslation();
Expand All @@ -26,22 +28,26 @@ export default function ChatbotScreen() {
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined));
const [text, setText] = useState('');
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';

useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
const store = useChatStore.getState();
void store.initChatbot();
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [])
}, [isChatEnabled])
);

// Keep the assistant channel active while viewing so incoming messages don't inflate unread.
useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId);
}, [chatbotChannelId])
}, [chatbotChannelId, isChatEnabled])
);

const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]);
Expand All @@ -60,6 +66,22 @@ export default function ChatbotScreen() {
[currentUserId]
);

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: the assistant rides on the chat system, hide it too.
if (chatStatus === 'disabled') {
return <Redirect href="/" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
Expand Down
29 changes: 25 additions & 4 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Image } from 'expo-image';
import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { Circle, ShieldCheck } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -26,6 +26,7 @@ import { VStack } from '@/components/ui/vstack';
import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';
import { useChatStore } from '@/stores/chat/store';
import { useChatSystemStatus } from '@/stores/feature-flags/store';
import { securityStore } from '@/stores/security/store';
import { useToastStore } from '@/stores/toast/store';

Expand All @@ -43,6 +44,8 @@ export default function ChannelConversationScreen() {

const currentUserId = useAuthStore((s) => s.userId);
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';

const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId));
const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
Expand Down Expand Up @@ -72,6 +75,7 @@ export default function ChannelConversationScreen() {
// Mount: activate channel, join hub, load history and members.
useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
if (!channelId) return;
const store = useChatStore.getState();
store.setActiveChannel(channelId);
Expand All @@ -81,11 +85,12 @@ export default function ChannelConversationScreen() {
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [channelId])
}, [channelId, isChatEnabled])
);

// Fetch presence for the channel members (for the header online dot).
useEffect(() => {
if (!isChatEnabled) return;
const ids = (members ?? []).map((m) => m.UserId).filter((id): id is string => !!id && id !== currentUserId);
if (ids.length === 0) return;
const controller = new AbortController();
Expand All @@ -97,14 +102,15 @@ export default function ChannelConversationScreen() {
})
.catch(() => undefined);
return () => controller.abort();
}, [members, currentUserId]);
}, [members, currentUserId, isChatEnabled]);

// Mark read whenever the newest message changes while viewing.
useEffect(() => {
if (!isChatEnabled) return;
if (channelId && inverted.length > 0) {
void useChatStore.getState().markChannelRead(channelId);
}
}, [channelId, inverted.length]);
}, [channelId, inverted.length, isChatEnabled]);

const otherOnline = useMemo(() => {
if (!isDm) return false;
Expand Down Expand Up @@ -246,6 +252,21 @@ export default function ChannelConversationScreen() {

const title = channel ? getChannelDisplayName(channel, t) : t('chat.title');

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ title, headerShown: true, headerBackTitle: '' }} />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: block deep links (push notifications, stale routes).
if (chatStatus === 'disabled') {
return <Redirect href="/" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen
Expand Down
24 changes: 22 additions & 2 deletions src/app/chat/thread/[messageId].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { Redirect, Stack, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Platform } from 'react-native';
Expand All @@ -9,12 +9,14 @@ import { MessageComposer } from '@/components/chat/message-composer';
import { Box } from '@/components/ui/box';
import { Divider } from '@/components/ui/divider';
import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { logger } from '@/lib/logging';
import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';
import { useChatStore } from '@/stores/chat/store';
import { useChatSystemStatus } from '@/stores/feature-flags/store';

export default function ThreadScreen() {
const { t } = useTranslation();
Expand All @@ -26,18 +28,21 @@ export default function ThreadScreen() {
const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId));
const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
const [fetchedReplies, setFetchedReplies] = useState<ChatMessageResultData[]>([]);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Hardcoded string literal 'enabled' checks chat system status inline, creating synchronization risks across components. Define a constant like CHAT_STATUS.ENABLED or derive it from the feature-flag store's return type.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/app/chat/thread/[messageId].tsx:

Line 32:

Hardcoded string literal 'enabled' checks chat system status inline, creating synchronization risks across components. Define a constant like `CHAT_STATUS.ENABLED` or derive it from the feature-flag store's return type.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


// IC delta: thread replies in command-type channels also post as the Incident Commander.
const isCommandChannel = channel?.ChannelType === ChatChannelType.Incident || channel?.ChannelType === ChatChannelType.IncidentLane || channel?.ChannelType === ChatChannelType.IncidentCommand;

const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]);

useEffect(() => {
if (!isChatEnabled) return;
if (!messageId) return;
getThread(messageId, undefined, 50)
.then((response) => setFetchedReplies(response.Data ?? []))
.catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } }));
}, [messageId]);
}, [messageId, isChatEnabled]);

// Merge fetched replies with any realtime/optimistic replies already in the channel cache.
const replies = useMemo(() => {
Expand Down Expand Up @@ -100,6 +105,21 @@ export default function ThreadScreen() {
[currentUserId, channelId]
);

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: block deep links into threads.
if (chatStatus === 'disabled') {
return <Redirect href="/" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />
Expand Down
7 changes: 6 additions & 1 deletion src/components/sidebar/sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { useIsChatEnabled } from '@/stores/feature-flags/store';

interface SidebarProps {
onClose?: () => void;
Expand All @@ -35,6 +36,10 @@ const MENU_ITEMS: MenuItem[] = [
const Sidebar = ({ onClose }: SidebarProps) => {
const { t } = useTranslation();
const router = useRouter();
const isChatEnabled = useIsChatEnabled();

// Chat.System feature flag off: hide the chat and assistant entries entirely.
const menuItems = MENU_ITEMS.filter((item) => (item.key === 'chat' || item.key === 'chatbot' ? isChatEnabled : true));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic strings: the literals 'chat' and 'chatbot' are used as inline item-key comparisons but are shared domain identifiers also referenced in MENU_ITEMS definitions and route configs. Define constants (e.g., MENU_KEY.CHAT = 'chat', MENU_KEY.CHATBOT = 'chatbot') or a const tuple and reference them in both the filter and the MENU_ITEMS array.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/components/sidebar/sidebar-content.tsx:

Line 42:

Magic strings: the literals `'chat'` and `'chatbot'` are used as inline item-key comparisons but are shared domain identifiers also referenced in `MENU_ITEMS` definitions and route configs. Define constants (e.g., `MENU_KEY.CHAT = 'chat'`, `MENU_KEY.CHATBOT = 'chatbot'`) or a const tuple and reference them in both the filter and the `MENU_ITEMS` array.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic strings: the literals 'chat' and 'chatbot' represent a finite set of menu item keys but lack compile-time safety, making refactoring error-prone. Declare an enum or as-const tuple (e.g., const MENU_KEYS = ['chat','chatbot',...] as const) and compare against MENU_KEYS.Chat / MENU_KEYS.Chatbot instead of raw strings.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/components/sidebar/sidebar-content.tsx:

Line 42:

Magic strings: the literals `'chat'` and `'chatbot'` represent a finite set of menu item keys but lack compile-time safety, making refactoring error-prone. Declare an enum or as-const tuple (e.g., `const MENU_KEYS = ['chat','chatbot',...] as const`) and compare against `MENU_KEYS.Chat` / `MENU_KEYS.Chatbot` instead of raw strings.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


const handleNavigate = (href: string) => {
onClose?.();
Expand All @@ -46,7 +51,7 @@ const Sidebar = ({ onClose }: SidebarProps) => {
<VStack space="xs" className="w-full flex-1 p-2">
<Text className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{t('sidebar.menu')}</Text>

{MENU_ITEMS.map((item) => (
{menuItems.map((item) => (
<Pressable key={item.key} testID={`sidebar-link-${item.key}`} className="flex-row items-center gap-3 rounded-lg px-3 py-3 active:bg-gray-100 dark:active:bg-gray-800" onPress={() => handleNavigate(item.href)}>
<Icon as={item.icon} size="lg" className="text-primary-500 dark:text-primary-400" />
<Text className="flex-1 text-base font-medium text-gray-900 dark:text-white">{t(item.labelKey)}</Text>
Expand Down
Loading
Loading