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
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;
Comment on lines 8 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the required API endpoint wrapper.

These helpers call the Axios client directly. Route them through createApiEndpoint or createCachedApiEndpoint to follow the required API-module boundary.

As per coding guidelines, always use createApiEndpoint or createCachedApiEndpoint for API endpoints with typed response generics.

Also applies to: 17-22

🤖 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/api/chat/chatbot.ts` around lines 8 - 10, Update getChatbotChannel and
the other affected chatbot helpers to use the required createApiEndpoint or
createCachedApiEndpoint wrapper instead of calling api.get directly, while
preserving their typed ChatbotChannelResponse handling, abort signal support,
and existing response values.

Source: Coding guidelines

};

/**
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
7 changes: 7 additions & 0 deletions src/app/(app)/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ export default function ChatScreen() {

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);

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 vulnerability caused by hardcoding the route path '/chatbot' as a raw string literal. Define a centralized routes module (e.g., export const Routes = { Chatbot: '/chatbot' } as const) and reference it here to ensure refactor safety.

Kody rule violation: Centralize string constants

Prompt for LLM

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

Line 110:

Magic string vulnerability caused by hardcoding the route path '/chatbot' as a raw string literal. Define a centralized routes module (e.g., `export const Routes = { Chatbot: '/chatbot' } as const`) and reference it here to ensure refactor safety.

Talk to Kody by mentioning @kody

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

return;
}
router.push(`/chat/${channelId}` as Href);
},
[router]
Expand Down
64 changes: 63 additions & 1 deletion src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
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';
Expand All @@ -16,11 +20,14 @@
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();
Expand All @@ -30,7 +37,11 @@
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(() => {
Expand Down Expand Up @@ -61,7 +72,7 @@

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 75 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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hide reaction controls in assistant messages.

If an assistant message has existing reactions, MessageBubble renders pressable reaction controls and invokes this no-op callback. The user can tap a visible control that does nothing.

Pass an explicit assistant or disableReactions prop to MessageBubble and hide those controls in assistant mode.

As per coding guidelines, avoid anonymous functions in renderItem or event handlers to prevent re-renders.

🤖 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 at line 75, Update the MessageBubble usage in
renderItem to explicitly disable reactions for assistant messages, using the
component’s existing assistant or disableReactions prop so reaction controls are
hidden. Replace the inline no-op onToggleReaction callback with the appropriate
stable handler or omit it when reactions are disabled, avoiding anonymous
functions in renderItem.

Source: Coding guidelines

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

Performance regression caused by inline arrow functions in JSX props, which create new function instances on every render. Move these function definitions outside the render method to prevent unnecessary re-renders.

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

Prompt for LLM

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

Line 75:

Performance regression caused by inline arrow functions in JSX props, which create new function instances on every render. Move these function definitions outside the render method to prevent unnecessary re-renders.

Talk to Kody by mentioning @kody

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

),
[currentUserId]
);
Expand Down Expand Up @@ -138,6 +149,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)}

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 occurs because the flagMessage store action lacks a .catch() handler when invoked by the UI event handler. Append .catch(() => useToastStore.getState().showToast('error', t('chat.flag_failed'))) to the useChatStore.getState().flagMessage call 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 172:

Unhandled promise rejection occurs because the `flagMessage` store action lacks a `.catch()` handler when invoked by the UI event handler. Append `.catch(() => useToastStore.getState().showToast('error', t('chat.flag_failed')))` to the `useChatStore.getState().flagMessage` call 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.

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());
}
setEditMessage(null);
}}
>
<ButtonText>{t('chat.save')}</ButtonText>
</Button>
</VStack>
</ActionsheetContent>
</Actionsheet>
</Box>
);
}
44 changes: 39 additions & 5 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,18 +59,35 @@ export default function ChannelConversationScreen() {
const [editText, setEditText] = useState('');
const [imageUri, setImageUri] = useState<string | null>(null);
const [presenceIds, setPresenceIds] = useState<Set<string>>(new Set());
const [resolveAttempted, setResolveAttempted] = useState(false);
const unsubscribeRef = useRef<(() => void) | null>(null);

const isDm = channel?.ChannelType === ChatChannelType.DirectMessage;
const isChatbot = channel?.ChannelType === ChatChannelType.Chatbot;
// Deep links (push notifications, cold starts) can arrive before the channel
// list loads; the channel type is unknown until then. Treat a completed fetch
// with no match as resolved so unknown channels keep the generic screen.
const isResolved = !!channel || resolveAttempted;
const showSender = !isDm;

// Chronological order (oldest-first); FlashList renders bottom-anchored via maintainVisibleContentPosition.
const ordered = useMemo(() => messages ?? [], [messages]);

// Mount: activate channel, join hub, load history and members.
// Resolve the channel identity for deep links before mounting the generic view.
useEffect(() => {
if (channel || resolveAttempted || !isChatEnabled) return;
void useChatStore
.getState()
.fetchChannels()
.finally(() => setResolveAttempted(true));
Comment on lines +79 to +82

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 occurs because the fetchChannels() promise chains .finally() without a .catch() handler, risking app crashes and leaving resolveAttempted unset. Add a .catch() block to log the error context before executing .finally(() => setResolveAttempted(true)).

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

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

Line 79 to 82:

Unhandled promise rejection occurs because the `fetchChannels()` promise chains `.finally()` without a `.catch()` handler, risking app crashes and leaving `resolveAttempted` unset. Add a `.catch()` block to log the error context before executing `.finally(() => setResolveAttempted(true))`.

Talk to Kody by mentioning @kody

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

}, [channel, resolveAttempted, isChatEnabled]);

// Mount: activate channel, join hub, load history and members. Assistant
// conversations are handled by the dedicated chatbot screen — never join or
// load them here, and wait for unresolved deep links to identify first.
useFocusEffect(
useCallback(() => {
if (!channelId || !isChatEnabled) return;
if (!channelId || !isChatEnabled || !isResolved || isChatbot) return;
const store = useChatStore.getState();
store.setActiveChannel(channelId);
void store.joinChannel(channelId);
Expand All @@ -79,7 +96,7 @@ export default function ChannelConversationScreen() {
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [channelId, isChatEnabled])
}, [channelId, isChatEnabled, isResolved, isChatbot])
);

// Fetch presence for the channel members (for the header online dot).
Expand All @@ -99,10 +116,10 @@ export default function ChannelConversationScreen() {

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

const otherOnline = useMemo(() => {
if (!isDm) return false;
Expand Down Expand Up @@ -257,6 +274,23 @@ export default function ChannelConversationScreen() {
return <Redirect href="/(app)" />;
}

// Deep link to a channel that isn't loaded yet: wait for the channel list so
// assistant conversations never mount the full-featured view.
if (!isResolved) {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ title, headerShown: true, headerBackTitle: '' }} />
<Spinner />
</Box>
);
}

// Assistant conversations always use the dedicated restricted screen (text only,
// no reactions/threads/deletes) — catch deep links and stale routes here.
if (isChatbot) {
return <Redirect href={'/chatbot' as Href} />;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen
Expand Down
2 changes: 1 addition & 1 deletion src/app/chat/thread/[messageId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
contentContainerStyle={{ paddingVertical: 8 }}
/>

<MessageComposer onSendText={handleSendText} onSendImage={() => undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} />
<MessageComposer onSendText={handleSendText} onSendImage={() => undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} />

Check warning on line 138 in src/app/chat/thread/[messageId].tsx

View workflow job for this annotation

GitHub Actions / test

Replace `·onSendText={handleSendText}·onSendImage={()·=>·undefined}·onSendLocation={handleSendLocation}·onOpenGif={handleSendGif}·onTyping={()·=>·undefined}·placeholder={t('chat.reply_placeholder')}·allowUrgent={false}` with `⏎··········onSendText={handleSendText}⏎··········onSendImage={()·=>·undefined}⏎··········onSendLocation={handleSendLocation}⏎··········onOpenGif={handleSendGif}⏎··········onTyping={()·=>·undefined}⏎··········placeholder={t('chat.reply_placeholder')}⏎··········allowUrgent={false}⏎·······`
</KeyboardAvoidingView>
</Box>
);
Expand Down
55 changes: 54 additions & 1 deletion src/components/chat/__tests__/chat-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as Clipboard from 'expo-clipboard';
import { type TFunction } from 'i18next';

import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat';

import { getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';
import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';

jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() }));

const mockT = ((key: string) => key) as TFunction;

Expand Down Expand Up @@ -94,4 +97,54 @@ describe('chat-utils', () => {
expect(linkifySegments('')).toEqual([]);
});
});

describe('copyToClipboard', () => {
const globalWithNavigator = globalThis as unknown as { navigator?: { clipboard?: { writeText?: (value: string) => Promise<void> } } };
let originalNavigator: unknown;

beforeEach(() => {
originalNavigator = globalWithNavigator.navigator;
jest.mocked(Clipboard.setStringAsync).mockReset();
});

afterEach(() => {
if (originalNavigator === undefined) {
delete globalWithNavigator.navigator;
} else {
globalWithNavigator.navigator = originalNavigator as typeof globalWithNavigator.navigator;
}
});

it('uses the web clipboard API when available', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
globalWithNavigator.navigator = { clipboard: { writeText } };

await expect(copyToClipboard('hello')).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('hello');
expect(Clipboard.setStringAsync).not.toHaveBeenCalled();
});

it('falls back to the native module when the web API is unavailable', async () => {
delete globalWithNavigator.navigator;
jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true);

await expect(copyToClipboard('hello')).resolves.toBe(true);
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello');
});

it('falls back to the native module when the web API write fails', async () => {
globalWithNavigator.navigator = { clipboard: { writeText: jest.fn().mockRejectedValue(new Error('denied')) } };
jest.mocked(Clipboard.setStringAsync).mockResolvedValue(true);

await expect(copyToClipboard('hello')).resolves.toBe(true);
expect(Clipboard.setStringAsync).toHaveBeenCalledWith('hello');
});

it('returns false when the native write fails', async () => {
delete globalWithNavigator.navigator;
jest.mocked(Clipboard.setStringAsync).mockRejectedValue(new Error('unavailable'));

await expect(copyToClipboard('hello')).resolves.toBe(false);
});
});
});
15 changes: 10 additions & 5 deletions src/components/chat/chat-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Clipboard from 'expo-clipboard';
import { type TFunction } from 'i18next';

import { getAvatarUrl } from '@/lib/utils';
Expand Down Expand Up @@ -105,9 +106,9 @@ export function hasLink(body?: string | null): boolean {
}

/**
* Copies text to the clipboard. Works on web/Electron via the async Clipboard
* API; native returns false (no clipboard native module is installed) so callers
* can surface an appropriate message.
* Copies text to the clipboard. Uses the async Clipboard API on web/Electron
* and expo-clipboard on native; returns false only when both are unavailable
* or the write fails, so callers can surface an appropriate message.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
Expand All @@ -117,9 +118,13 @@ export async function copyToClipboard(text: string): Promise<boolean> {
return true;
}
} catch {
// ignore and fall through
// ignore and fall through to the native module
}
try {
return await Clipboard.setStringAsync(text);
} catch {
return false;
}
Comment on lines +123 to 127

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

Void return mismatch causes a successful native copy to evaluate as falsy, triggering the 'copy_unavailable' toast in [channelId].tsx:362 and chatbot.tsx:164 because Clipboard.setStringAsync resolves void instead of a boolean. Explicitly return true after the awaited call and update the test's .mockResolvedValue(true) to match the library.

try {
  await Clipboard.setStringAsync(text);
  return true;
} catch {
  return false;
}
Prompt for LLM

File src/components/chat/chat-utils.ts:

Line 123 to 127:

Void return mismatch causes a successful native copy to evaluate as falsy, triggering the 'copy_unavailable' toast in `[channelId].tsx:362` and `chatbot.tsx:164` because `Clipboard.setStringAsync` resolves `void` instead of a boolean. Explicitly return `true` after the awaited call and update the test's `.mockResolvedValue(true)` to match the library.

Suggested Code:

  try {
    await Clipboard.setStringAsync(text);
    return true;
  } catch {
    return false;
  }

Talk to Kody by mentioning @kody

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

return false;
}

const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
Expand Down
Loading
Loading