Skip to content
Open
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
14 changes: 14 additions & 0 deletions docs/audio-stream-refactoring.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Audio Stream Store Refactoring

## Expo SDK 56 migration requirement

Before upgrading Dispatch to Expo SDK 56, upgrade `expo-audio` to the SDK 56-compatible version and replace all remaining `expo-av` audio usage with `expo-audio`. SDK 56 no longer provides the legacy Expo Modules Core header required by `expo-av` 16, so leaving `expo-av` installed can break the iOS archive build.
Comment on lines +3 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate document and relevant docs/config:"
fd -a 'audio-stream-refactoring\.md|app\.json|app\.config\.(js|ts)|package\.json|expo\.config\.(js|ts)|babel\.config' . | sed 's#^\./##' | head -200

echo
echo "Show audio-stream-reffactoring.md context:"
if [ -f docs/audio-stream-refactoring.md ]; then
  nl -ba docs/audio-stream-refactoring.md | sed -n '1,220p'
fi

echo
echo "Find expo versions and audio packages:"
rg -n '"expo"|"react-native"|"expo-audio"|"expo-av"|"babel-preset-expo"|SDK 56|SDK 54' -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: Resgrid/Dispatch

Length of output: 433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Audio-refactoring context:"
awk '{ printf "%6d	%s\n", NR, $0 }' docs/audio-stream-refactoring.md | sed -n '1,220p'

echo
echo "Package/Expo config snippets:"
for f in app.config.ts babel.config.js package.json; do
  echo "--- $f ---"
  awk '{ printf "%6d	%s\n", NR, $0 }' "$f" | sed -n '1,220p'
done

echo
echo "Search relevant strings:"
rg -n '"expo"|"react-native"|"expo-audio"|"expo-av"|"babel-preset-expo"|SDK 56|SDK 54' -S --glob '!node_modules' --glob '!dist' --glob '!build' .

Repository: Resgrid/Dispatch

Length of output: 27844


🌐 Web query:

Expo SDK 54 expo-audio 1.1 installable expo-audio 16 expo-audio SDK 56 compatibility expo doc

💡 Result:

The expo-audio library is fully compatible with and designed for modern Expo SDKs, including SDK 54 and SDK 56. 1. Expo SDK 54: expo-audio 1.1 is compatible and was the standard for audio in SDK 54 [1][2]. During the SDK 54 lifecycle, users were encouraged to migrate to expo-audio from the deprecated expo-av [3][4]. You can install the version appropriate for your SDK using the command: npx expo install expo-audio [5]. 2. Expo SDK 56: expo-audio version 56.x is the version associated with SDK 56 [6][7]. The library received significant updates in SDK 56, including a new useAudioStream hook and various live-streaming improvements [8][9]. Important Note on Versions: In the Expo ecosystem, you should not manually choose a version number like 16 for expo-audio unless it specifically aligns with your SDK. Instead, always use the command npx expo install expo-audio [5]. This command ensures that the Expo CLI automatically installs the specific version of the package that is tested and compatible with your current Expo SDK version [5]. Using version 16 with SDK 56 is likely incorrect, as the package versions for expo-audio follow the SDK release numbering (e.g., version 56.x for SDK 56) [6][7]. Always verify your dependencies using npx expo-doctor after installation [5].

Citations:


🌐 Web query:

docs.expo.dev "expo-audio" "Expo SDK 56"

💡 Result:

In Expo SDK 56, the expo-audio library is officially supported and includes several key updates [1][2]. Key features and improvements for expo-audio in SDK 56 include: - New useAudioStream Hook: This hook provides real-time access to microphone buffer data, enabling new possibilities for audio processing [1][3]. - Live-Stream Enhancements: - An isLiveStream option is now available for the lock-screen on iOS [1][2]. - A playsInSilentMode option has been added for Android [1][3]. - The AudioStatus object has been updated to include new fields: isLive, currentOffsetFromLive, and error [1][2]. You can access the official documentation for this version at https://docs.expo.dev/versions/v56.0.0/sdk/audio/ [1][2].

Citations:


🌐 Web query:

Expo docs "Before upgrading" "During the upgrade" package compatibility SDK 56 expo install

💡 Result:

To upgrade your project to Expo SDK 56, follow these recommended practices regarding preparation, package compatibility, and the installation process [1][2][3]. Preparation and Best Practices Before starting the upgrade, it is strongly recommended that you read the official SDK 56 changelog and breaking changes documentation [1][4]. This allows you to identify necessary configuration adjustments specific to your application before beginning the actual upgrade process [1]. Additionally, use a dedicated git branch for the upgrade to isolate changes [2][3]. If you are still using Expo Go for development, it is recommended to migrate to development builds first, as this provides a more stable baseline for testing and debugging during and after the upgrade [5]. It is also advised to upgrade one major change at a time (e.g., upgrading the SDK version first, then enabling the New Architecture separately) to make it easier to isolate and troubleshoot any issues [5][4][6]. Package Compatibility and Installation During the upgrade, you should prioritize ensuring that your dependencies are compatible with the new SDK version [2][3]. The primary command for updating your Expo-related packages is [2][3]: npx expo install expo@^56.0.0 After updating the core expo package, run the package-aware installer to automatically align your remaining dependencies with the requirements of SDK 56 [2][3]: npx expo install --fix Post-Upgrade Verification Once the installation is complete, it is crucial to verify your project's health. The recommended next step is to run Expo Doctor, which is designed to detect mismatched versions, incompatible dependencies, and configuration issues [2][3]: npx expo-doctor If your project utilizes Expo Router, be aware that SDK 56 includes internal migration work; if you encounter issues, you may need to use the provided codemod to handle transitions between file-based routing and React Navigation imports [2][3]: npx expo-codemod sdk-56-expo-router-react-navigation-replace src Finally, rebuild your development builds and test thoroughly before deploying to production [2][3].

Citations:


Sequence the SDK 56 package update with the SDK 56 upgrade.

Expo’s SDK upgrade process updates Expo packages with the SDK and uses npx expo install --fix to align dependencies. Keep this guidance focused on the SDK upgrade, or change “Before upgrading” to “During the SDK 56 upgrade” and add the tested expo-audio package version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/audio-stream-refactoring.md` around lines 3 - 5, Update the “Expo SDK 56
migration requirement” guidance to sequence the expo-audio dependency update
during the SDK 56 upgrade rather than before it, and describe alignment through
the SDK upgrade process. If specifying a package version, use the tested SDK
56-compatible expo-audio version; otherwise remove the unsupported
prerequisite-version wording while retaining the expo-av replacement
requirement.

Source: MCP tools


Migration checklist:

- Migrate `src/hooks/use-ptt.ts`, `src/components/calls/call-audio-modal.tsx`, `src/stores/app/audio-stream-store.ts`, and `src/services/audio.service.ts` to `createAudioPlayer`, `setAudioModeAsync`, `AudioPlayer`, and `playbackStatusUpdate`.
- Remove `expo-av` from `package.json`, the lockfile, tests/mocks, and the Expo Doctor exclusion after no imports remain.
Comment on lines +9 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
if [ -f docs/audio-stream-refactoring.md ]; then
  wc -l docs/audio-stream-refactoring.md
  sed -n '1,220p' docs/audio-stream-refactoring.md
else
  echo "docs/audio-stream-refactoring.md not found"
fi

echo
echo "== expo-av mentions in docs/audio-stream-refactoring.md =="
rg -n "expo-av|expo-audio|audio-av|createAudioPlayer|setAudioModeAsync|AudioPlayer|playbackStatusUpdate|Migrate|Remove" docs/audio-stream-refactoring.md || true

Repository: Resgrid/Dispatch

Length of output: 7247


🌐 Web query:

Expo SDK 56 expo-av expo-audio expo-audio SDK 56 createAudioPlayer AudioPlayer

💡 Result:

In Expo SDK 56, the expo-audio library is the standard tool for audio playback and recording, having replaced the deprecated expo-av package [1][2]. To work with audio, you primarily use the useAudioPlayer hook or, in advanced scenarios where the player must persist beyond a component's lifecycle, the createAudioPlayer function [3][4]. 1. useAudioPlayer(source, options): This is the recommended approach for most use cases [3]. It is a React hook that automatically manages the lifecycle of the AudioPlayer instance, ensuring that resources are properly disposed of when the component unmounts [5][6]. 2. createAudioPlayer(source, options): This function allows you to create an AudioPlayer instance manually [3][5]. Because this instance is not automatically managed by a component's lifecycle, it is your responsibility to call the release method on the player when it is no longer needed to prevent memory leaks [3][4]. Key differences when migrating from expo-av include: - Time is measured in seconds rather than milliseconds [7]. - Audio loads immediately upon creation [7]. - There is no automatic reset to the beginning when playback finishes; if you wish to replay, you must manually call player.seekTo(0) before calling play [7][8]. For additional functionality, expo-audio also provides the useAudioRecorder hook for recording [7][3].

Citations:


Align expo-av references with the migration goal.

Lines 9-10 remove expo-av, but later sections describe expo-av, the Overview, the Key Changes, and the Installation steps still instruct using expo-av. Rewrite those sections to describe the expo-audio SDK 56 implementation, or label them as an SDK 54 baseline only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/audio-stream-refactoring.md` around lines 9 - 10, Update the later
Overview, Key Changes, and Installation sections in the audio-stream refactoring
document to consistently describe the expo-audio SDK 56 migration using
createAudioPlayer, setAudioModeAsync, AudioPlayer, and playbackStatusUpdate;
remove or explicitly label any remaining expo-av guidance as an SDK 54 baseline.

Source: MCP tools

- Keep this as an audio-only migration. Dispatch does not currently use the `expo-av` video component, so `expo-video` is not required for this change.
- Re-test remote MP3 streams on physical iOS and Android devices. This store originally moved to `expo-av` because remote streams had problems with the earlier `expo-audio` implementation.
- Also test PTT, call audio, background playback, interruptions, and Bluetooth/headset routing before release.

Do not copy an SDK 56 implementation back into the current SDK 54 app unchanged. Dispatch's current `expo-audio` 1.1 API does not expose SDK 56 options such as `preferredForwardBufferDuration` or playback `status.error`.

## Overview

The audio stream store has been refactored to use `expo-av` instead of `expo-audio` to resolve issues with playing remote MP3 streams over the internet in the new Expo architecture.
Expand Down
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) => {
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

Unguarded external HTTP call via api.get violates Rule [27] by lacking contextual error mapping. Wrap the call in a try/catch block, attach the operation name and endpoint, and map errors to a feature-flags domain error or safe default.

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

Prompt for LLM

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

Line 29:

Unguarded external HTTP call via `api.get` violates Rule [27] by lacking contextual error mapping. Wrap the call in a try/catch block, attach the operation name and endpoint, and map errors to a feature-flags domain error or safe default.

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;
};
36 changes: 26 additions & 10 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultDat
import { usePushNotifications } from '@/services/push-notification';
import { useCoreStore } from '@/stores/app/core-store';
import { useCallsStore } from '@/stores/calls/store';
import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store';
import useLockscreenStore from '@/stores/lockscreen/store';
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
Expand Down Expand Up @@ -150,7 +151,14 @@ export default function TabLayout() {
await securityStore.getState().getRights();

logger.info({
message: 'Security rights retrieved, connecting SignalR',
message: 'Security rights retrieved, fetching feature flags',
context: { platform: Platform.OS },
});

await featureFlagsStore.getState().fetchFlags();

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

Unguarded awaited fetchFlags() call risks unhandled rejections that fail app initialization. Wrap the call in a try/catch block and log the error context to comply with Rule [1].

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

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

Line 158:

Unguarded awaited `fetchFlags()` call risks unhandled rejections that fail app initialization. Wrap the call in a try/catch block and log the error context to comply with Rule [1].

Talk to Kody by mentioning @kody

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


logger.info({
message: 'Feature flags fetched, connecting SignalR',
context: { platform: Platform.OS },
});

Expand All @@ -169,18 +177,26 @@ export default function TabLayout() {
// Don't fail initialization if SignalR connection fails
}

// Connect the realtime chat hub (best-effort; chat may be disabled per department)
try {
await useSignalRStore.getState().connectChatHub();
// Connect the realtime chat hub only when the Chat.System feature flag is on for

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 Bug high

Feature flag bypass occurs in useSignalRLifecycle, which unconditionally reconnects the chat hub on app resume via signalRStore.connectChatHub() at use-signalr-lifecycle.ts:121, defeating the Chat.System gate at _layout.tsx:182. Thread the feature flag into the resume reconnect path in handleAppResume to skip connectChatHub() when featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem) is false.

// Gate must also be enforced in use-signalr-lifecycle.ts handleAppResume:
// const hubs = [signalRStore.connectUpdateHub(), signalRStore.connectGeolocationHub()];
// if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
//   hubs.push(signalRStore.connectChatHub());
// }
// const results = await Promise.allSettled(hubs);
Prompt for LLM

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

Line 180:

Feature flag bypass occurs in `useSignalRLifecycle`, which unconditionally reconnects the chat hub on app resume via `signalRStore.connectChatHub()` at `use-signalr-lifecycle.ts:121`, defeating the `Chat.System` gate at `_layout.tsx:182`. Thread the feature flag into the resume reconnect path in `handleAppResume` to skip `connectChatHub()` when `featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)` is false.

Suggested Code:

// Gate must also be enforced in use-signalr-lifecycle.ts handleAppResume:
// const hubs = [signalRStore.connectUpdateHub(), signalRStore.connectGeolocationHub()];
// if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
//   hubs.push(signalRStore.connectChatHub());
// }
// const results = await Promise.allSettled(hubs);

Talk to Kody by mentioning @kody

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

// this department; when it is off every chat surface stays hidden.
if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
try {
await useSignalRStore.getState().connectChatHub();
logger.info({
message: 'SignalR chat hub connected successfully',
context: { platform: Platform.OS },
});
} catch (error) {
logger.error({
message: 'Failed to connect SignalR chat hub during initialization',
context: { error, platform: Platform.OS },
});
}
} else {
logger.info({
message: 'SignalR chat hub connected successfully',
message: 'Chat disabled by feature flag; skipping chat hub connection',
context: { platform: Platform.OS },
});
} catch (error) {
logger.error({
message: 'Failed to connect SignalR chat hub during initialization',
context: { error, platform: Platform.OS },
});
}

// Initialize weather alerts
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,17 +15,19 @@
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();
const unread = channel.UnreadCount > 0;
const isDm = channel.ChannelType === ChatChannelType.DirectMessage;

const Leading = () => {

Check warning on line 30 in src/app/(app)/chat.tsx

View workflow job for this annotation

GitHub Actions / test

Do not define components during render. React will see a new component type on every render and destroy the entire subtree’s DOM nodes and state (https://reactjs.org/docs/reconciliation.html#elements-of-different-types). Instead, move this component definition out of the parent component “ChannelRow” and pass data as props
if (isDm) {
return (
<Avatar size="md">
Expand Down Expand Up @@ -81,6 +83,8 @@
export default function ChatScreen() {
const { t } = useTranslation();
const router = useRouter();
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
const channels = useChatStore((s) => s.channels);
const isLoading = useChatStore((s) => s.isLoadingChannels);
const pendingAcks = useChatStore((s) => s.pendingAcks);
Expand All @@ -89,9 +93,10 @@

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

const grouped = groupChannels(channels);
Expand All @@ -103,6 +108,22 @@
[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={'/home' as Href} />;

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 string '/home' for a finite application route reduces maintainability and risks typos. Define route names as a Route enum or const tuple instead of using raw strings.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

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

Line 111:

Magic string '/home' for a finite application route reduces maintainability and risks typos. Define route names as a Route enum or const tuple instead of using raw strings.

Talk to Kody by mentioning @kody

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

}

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 { type Href, 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 @@ -14,14 +14,18 @@ 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();
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
const currentUserId = useAuthStore((s) => s.userId);
const chatbotChannelId = useChatStore((s) => s.chatbotChannelId);
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
Expand All @@ -30,19 +34,21 @@ export default function ChatbotScreen() {

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 @@ -61,6 +67,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={'/home' as Href} />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
Expand Down
24 changes: 21 additions & 3 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 } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -27,6 +27,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 @@ -38,6 +39,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';

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 string comparison introduces silent failures if the store value changes or a typo occurs. Define a const tuple like const ChatSystemStatus = { Enabled: 'enabled', Unknown: 'unknown', Disabled: 'disabled' } as const in the feature-flags store and reference ChatSystemStatus.Enabled to enforce compile-time safety.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 43:

Magic string comparison introduces silent failures if the store value changes or a typo occurs. Define a const tuple like `const ChatSystemStatus = { Enabled: 'enabled', Unknown: 'unknown', Disabled: 'disabled' } as const` in the feature-flags store and reference `ChatSystemStatus.Enabled` to enforce compile-time safety.

Talk to Kody by mentioning @kody

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


const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId));
const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
Expand All @@ -64,7 +67,7 @@ export default function ChannelConversationScreen() {
// Mount: activate channel, join hub, load history and members.
useFocusEffect(
useCallback(() => {
if (!channelId) return;
if (!channelId || !isChatEnabled) return;
const store = useChatStore.getState();
store.setActiveChannel(channelId);
void store.joinChannel(channelId);
Expand All @@ -73,7 +76,7 @@ export default function ChannelConversationScreen() {
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [channelId])
}, [channelId, isChatEnabled])
);

// Fetch presence for the channel members (for the header online dot).
Expand Down Expand Up @@ -236,6 +239,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={'/home' as Href} />;

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 route path '/home' scatters definitions, causing renames or typos to evade compile-time checks. Reference a centralized routes constant, such as ROUTES.HOME or AppRoutes.Home, defined in a single navigation module.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 254:

Hardcoded route path '/home' scatters definitions, causing renames or typos to evade compile-time checks. Reference a centralized routes constant, such as `ROUTES.HOME` or `AppRoutes.Home`, defined in a single navigation module.

Talk to Kody by mentioning @kody

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

}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen
Expand Down
25 changes: 22 additions & 3 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 { type Href, Redirect, Stack, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform } from 'react-native';
Expand All @@ -10,31 +10,35 @@ import { Box } from '@/components/ui/box';
import { Divider } from '@/components/ui/divider';
import { FlatList } from '@/components/ui/flat-list';
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 { 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();
const params = useLocalSearchParams<{ messageId: string; channelId: string }>();
const messageId = Array.isArray(params.messageId) ? params.messageId[0] : params.messageId;
const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId;

const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
const currentUserId = useAuthStore((s) => s.userId);
const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
const [fetchedReplies, setFetchedReplies] = useState<ChatMessageResultData[]>([]);

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

useEffect(() => {
if (!messageId) return;
if (!messageId || !isChatEnabled) 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 @@ -96,6 +100,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={'/home' as Href} />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />
Expand Down
Loading
Loading