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
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"expo-auth-session": "~7.0.11",
"expo-av": "~16.0.8",
"expo-build-properties": "~1.0.10",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-dev-client": "~6.0.21",
Expand Down
4 changes: 2 additions & 2 deletions src/api/chat/chatbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const CHATBOT = '/Chatbot';
/** Gets (creating if needed) the caller's chatbot conversation channel. */
export const getChatbotChannel = async (signal?: AbortSignal) => {
const response = await api.get<ChatbotChannelResponse>(`${CHATBOT}/GetChatChannel`, { signal });
return response.data;
return response.data?.Data ?? null;
};

/**
Expand All @@ -19,7 +19,7 @@ export const sendChatbotMessage = async (text: string, clientMessageId: string)
Text: text,
ClientMessageId: clientMessageId,
});
return response.data;
return response.data?.Data ?? null;
};

/** Resets the chatbot conversational session (message history is retained). */
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
32 changes: 30 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,20 +93,44 @@

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

const grouped = groupChannels(channels);

const openChannel = useCallback(
(channelId: string) => {
// The assistant conversation always opens in its dedicated restricted screen
// (text only, no reactions/threads/deletes) instead of the generic conversation.
const channel = useChatStore.getState().channels.find((c) => c.ChatChannelId === channelId);
if (channel?.ChannelType === ChatChannelType.Chatbot) {
router.push('/chatbot' as Href);
return;
}
router.push(`/chat/${channelId}` as Href);
},
[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
92 changes: 88 additions & 4 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,65 @@
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';
import { Platform } from 'react-native';

import { copyToClipboard } from '@/components/chat/chat-utils';
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
import { MessageBubble } from '@/components/chat/message-bubble';
import { TypingDots } from '@/components/chat/typing-indicator';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Center } from '@/components/ui/center';
import { FlatList } from '@/components/ui/flat-list';
import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
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 { Textarea, TextareaInput } from '@/components/ui/textarea';
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';
import { securityStore } from '@/stores/security/store';
import { useToastStore } from '@/stores/toast/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);
const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined));
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
const [text, setText] = useState('');
const [actionsMessage, setActionsMessage] = useState<ChatMessageResultData | null>(null);
const [editMessage, setEditMessage] = useState<ChatMessageResultData | null>(null);
const [editText, setEditText] = useState('');

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 @@ -56,11 +73,27 @@

const renderItem = useCallback(
({ item }: { item: ChatMessageResultData }) => (
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={() => undefined} onToggleReaction={() => undefined} />
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={setActionsMessage} onToggleReaction={() => undefined} />

Check warning on line 76 in src/app/(app)/chatbot.tsx

View workflow job for this annotation

GitHub Actions / test

Replace `·message={item}·isOwn={!!item.SenderUserId·&&·item.SenderUserId·===·currentUserId}·showSender={false}·currentUserId={currentUserId}·onLongPress={setActionsMessage}·onToggleReaction={()·=>·undefined}` with `⏎········message={item}⏎········isOwn={!!item.SenderUserId·&&·item.SenderUserId·===·currentUserId}⏎········showSender={false}⏎········currentUserId={currentUserId}⏎········onLongPress={setActionsMessage}⏎········onToggleReaction={()·=>·undefined}⏎·····`

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

Unnecessary re-renders occur in src/app/(app)/chatbot.tsx (lines 154, 158-161, 164, 168-171, 175, 188), src/app/chat/thread/[messageId].tsx:134, src/components/chat/message-actions-sheet.tsx:99, and src/components/chat/message-composer.tsx:138 because inline arrow functions and .bind() calls in JSX props create new function instances on every render. Move these function definitions outside the render method.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

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

Line 76:

Unnecessary re-renders occur in `src/app/(app)/chatbot.tsx` (lines 154, 158-161, 164, 168-171, 175, 188), `src/app/chat/thread/[messageId].tsx:134`, `src/components/chat/message-actions-sheet.tsx:99`, and `src/components/chat/message-composer.tsx:138` because inline arrow functions and `.bind()` calls in JSX props create new function instances on every render. Move these function definitions outside the render method.

Talk to Kody by mentioning @kody

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

),
[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 Expand Up @@ -113,6 +146,57 @@
</Pressable>
</HStack>
</KeyboardAvoidingView>

{/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */}
<MessageActionsSheet
message={actionsMessage}
isOpen={actionsMessage !== null}
onClose={() => setActionsMessage(null)}
isOwn={!!actionsMessage?.SenderUserId && actionsMessage.SenderUserId === currentUserId}
isModerator={isModerator}
assistant
onReact={() => undefined}
onReply={() => undefined}
onCopy={async (m) => {
const ok = await copyToClipboard(m.Body ?? '');
useToastStore.getState().showToast(ok ? 'success' : 'info', ok ? t('chat.copied') : t('chat.copy_unavailable'));
}}
onEdit={(m) => {
setEditMessage(m);
setEditText(m.Body ?? '');
}}
onDelete={() => undefined}
onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)}
onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)}
onModeratorDelete={() => undefined}
/>

{/* Edit own message */}
<Actionsheet isOpen={editMessage !== null} onClose={() => setEditMessage(null)}>
<ActionsheetBackdrop />
<ActionsheetContent>
<ActionsheetDragIndicatorWrapper>
<ActionsheetDragIndicator />
</ActionsheetDragIndicatorWrapper>
<VStack className="w-full p-2" space="md">
<Text className="text-base font-semibold text-typography-900">{t('chat.edit_message')}</Text>
<Textarea>
<TextareaInput value={editText} onChangeText={setEditText} multiline />
</Textarea>
<Button
className="bg-primary-600"
onPress={() => {
if (editMessage && chatbotChannelId && editText.trim()) {
void useChatStore.getState().editMessage(editMessage.ChatMessageId, chatbotChannelId, editText.trim());

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 risk exists in src/app/(app)/chatbot.tsx (lines 154, 161) because the promise returned by editMessage is discarded with the void operator, silently swallowing errors. Wrap the call in a try/catch block or chain a .catch() handler to display an error toast on failure.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

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

Line 190:

Unhandled promise rejection risk exists in `src/app/(app)/chatbot.tsx` (lines 154, 161) because the promise returned by `editMessage` is discarded with the `void` operator, silently swallowing errors. Wrap the call in a try/catch block or chain a `.catch()` handler to display an error toast on failure.

Talk to Kody by mentioning @kody

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

}
setEditMessage(null);
}}
Comment on lines +188 to +193

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 | 🟡 Minor | ⚡ Quick win

Do not silently discard an empty edit.

If editText.trim() is empty, the handler closes the sheet without saving or user feedback. Disable Save until text is non-empty, or show a validation message.

As per coding guidelines, “Handle errors gracefully and provide user feedback.”

🤖 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 `@src/app/`(app)/chatbot.tsx around lines 188 - 193, Update the edit-message
save handler around setEditMessage and the editMessage call so an empty
editText.trim() cannot silently close the sheet: disable the Save action while
trimmed text is empty or display a validation message and keep the editor open.
Preserve saving only when editMessage, chatbotChannelId, and non-empty trimmed
text are present.

Source: Coding guidelines

>
<ButtonText>{t('chat.save')}</ButtonText>
</Button>
</VStack>
</ActionsheetContent>
</Actionsheet>
</Box>
);
}
Loading
Loading