Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
52 changes: 13 additions & 39 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ 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 @@ -20,7 +18,6 @@ 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';
Expand All @@ -40,8 +37,6 @@ export default function ChatbotScreen() {
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 @@ -72,7 +67,14 @@ export default function ChatbotScreen() {

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

Performance impact from inline arrow functions in JSX props, which create new function instances on every render. Move the onToggleReaction definition 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:

Performance impact from inline arrow functions in JSX props, which create new function instances on every render. Move the `onToggleReaction` definition 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]
);
Expand Down Expand Up @@ -122,7 +124,9 @@ export default function ChatbotScreen() {
) : (
<FlatList
data={ordered}
maintainVisibleContentPosition={{ startRenderingFromBottom: true }}
// autoscrollToBottomThreshold is off by default in FlashList v2; without it a
// new answer lands below the viewport, hidden behind the input row.
maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2 }}
keyExtractor={(item: ChatMessageResultData) => item.ChatMessageId}
renderItem={renderItem}
contentContainerStyle={{ paddingVertical: 8 }}
Expand Down Expand Up @@ -150,7 +154,7 @@ export default function ChatbotScreen() {
</HStack>
</KeyboardAvoidingView>

{/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */}
{/* Restricted actions for assistant messages: copy, pin (moderator), flag. */}
<MessageActionsSheet
message={actionsMessage}
isOpen={actionsMessage !== null}
Expand All @@ -164,42 +168,12 @@ export default function ChatbotScreen() {
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 ?? '');
}}
onEdit={() => undefined}
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());
}
setEditMessage(null);
}}
>
<ButtonText>{t('chat.save')}</ButtonText>
</Button>
</VStack>
</ActionsheetContent>
</Actionsheet>
</Box>
);
}
8 changes: 7 additions & 1 deletion src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,15 @@ const navigationIntegration = Sentry.reactNavigationIntegration({
enableTimeToInitialDisplay: false,
});

// Sentry's own logger is off by default: watchdog-termination tracking rewrites the
// native scope on every RNSentry turbo-module call, so `debug` floods the Metro
// console with hundreds of "Writing tags to disk" lines a second. Flip to `__DEV__`
// temporarily when diagnosing Sentry itself.
const SENTRY_DEBUG = false;

Sentry.init({
dsn: Env.SENTRY_DSN,
debug: __DEV__, // Only debug in development, not production
debug: SENTRY_DEBUG,
tracesSampleRate: __DEV__ ? 0.1 : 0.2, // 10% in dev (low to avoid setTimeout wrapping overhead), 20% in production
profilesSampleRate: __DEV__ ? 0.1 : 0.2, // 10% in dev, 20% in production
sendDefaultPii: false,
Expand Down
12 changes: 7 additions & 5 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Platform } from 'react-native';

import { getPresence, uploadAttachment } from '@/api/chat/chat';
import { AckBanner } from '@/components/chat/ack-banner';
import { copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils';
import { buildGifMetadata, buildLocationMetadata, copyToClipboard, getChannelDisplayName, getImageMimeType } from '@/components/chat/chat-utils';
import { GifPickerSheet } from '@/components/chat/gif-picker-sheet';
import { MessageActionsSheet } from '@/components/chat/message-actions-sheet';
import { MessageBubble } from '@/components/chat/message-bubble';
Expand Down Expand Up @@ -155,7 +155,7 @@ export default function ChannelConversationScreen() {
const handleSendGif = useCallback(
(gif: GifResultData) => {
if (!channelId) return;
const metadata = JSON.stringify({ GifUrl: gif.GifUrl, PreviewUrl: gif.PreviewUrl, Width: gif.Width, Height: gif.Height, Title: gif.Title });
const metadata = buildGifMetadata(gif);
void useChatStore.getState().sendMessage({ channelId, body: gif.Title ?? 'GIF', messageType: ChatMessageType.Gif, metadataJson: metadata });
},
[channelId]
Expand All @@ -164,7 +164,7 @@ export default function ChannelConversationScreen() {
const handleSendLocation = useCallback(
(latitude: number, longitude: number, urgent: boolean) => {
if (!channelId) return;
const metadata = JSON.stringify({ Latitude: latitude, Longitude: longitude });
const metadata = buildLocationMetadata(latitude, longitude);
void useChatStore.getState().sendMessage({
channelId,
body: t('chat.shared_location'),
Expand Down Expand Up @@ -312,7 +312,9 @@ export default function ChannelConversationScreen() {
) : (
<FlatList
data={ordered}
maintainVisibleContentPosition={{ startRenderingFromBottom: true }}
// autoscrollToBottomThreshold is off by default in FlashList v2; without it a
// sent/incoming message lands below the viewport, hidden behind the composer.
maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2 }}

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

Unnamed magic number for the autoscrollToBottomThreshold ratio complicates future tuning and code review. Extract the 0.2 literal into a named constant like const AUTOSCROLL_TO_BOTTOM_THRESHOLD = 0.2; at the module or component scope.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

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

Line 317:

Unnamed magic number for the `autoscrollToBottomThreshold` ratio complicates future tuning and code review. Extract the `0.2` literal into a named constant like `const AUTOSCROLL_TO_BOTTOM_THRESHOLD = 0.2;` at the module or component scope.

Talk to Kody by mentioning @kody

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

​

​

keyExtractor={keyExtractor}
renderItem={renderItem}
onStartReached={handleStartReached}
Expand Down Expand Up @@ -354,7 +356,7 @@ export default function ChannelConversationScreen() {
handleToggleReaction(
m,
emoji,
m.Reactions.some((r) => r.Emoji === emoji && r.UserId === currentUserId)
(m.Reactions ?? []).some((r) => r.Emoji === emoji && r.UserId === currentUserId)
)
}
onReply={openThread}
Expand Down
17 changes: 14 additions & 3 deletions src/app/chat/thread/[messageId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Platform } from 'react-native';

import { getThread } from '@/api/chat/chat';
import { buildLocationMetadata } from '@/components/chat/chat-utils';
import { MessageBubble } from '@/components/chat/message-bubble';
import { MessageComposer } from '@/components/chat/message-composer';
import { Box } from '@/components/ui/box';
Expand Down Expand Up @@ -72,7 +73,7 @@ export default function ThreadScreen() {
channelId,
body: t('chat.shared_location'),
messageType: ChatMessageType.Location,
metadataJson: JSON.stringify({ Latitude: latitude, Longitude: longitude }),
metadataJson: buildLocationMetadata(latitude, longitude),
threadRootMessageId: messageId,
priority: urgent ? ChatMessagePriority.Urgent : ChatMessagePriority.Normal,
});
Expand Down Expand Up @@ -129,13 +130,23 @@ export default function ThreadScreen() {

<FlatList
data={replies}
maintainVisibleContentPosition={{ startRenderingFromBottom: true }}
// autoscrollToBottomThreshold is off by default in FlashList v2; without it a
// new reply lands below the viewport, hidden behind the composer.
maintainVisibleContentPosition={{ startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2 }}
keyExtractor={(item: ChatMessageResultData) => item.ChatMessageId}
renderItem={renderItem}
contentContainerStyle={{ paddingVertical: 8 }}
/>

<MessageComposer onSendText={handleSendText} onSendImage={() => undefined} onSendLocation={handleSendLocation} onOpenGif={handleSendGif} onTyping={() => undefined} placeholder={t('chat.reply_placeholder')} allowUrgent={false} />
<MessageComposer
onSendText={handleSendText}
onSendImage={() => undefined}
onSendLocation={handleSendLocation}
onOpenGif={handleSendGif}
onTyping={() => undefined}
placeholder={t('chat.reply_placeholder')}
allowUrgent={false}
/>
</KeyboardAvoidingView>
</Box>
);
Expand Down
47 changes: 45 additions & 2 deletions src/components/chat/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,55 @@ export function parseMetadata<T>(metadataJson?: string | null): T | null {
}
}

/**
* MetadataJson wire contract, shared with the web client: a nested, camelCase envelope
* — `{ location: { latitude, longitude, label } }` and `{ gif: { url, previewUrl, width,
* height } }`. Earlier mobile builds wrote a flat PascalCase object that the web client
* cannot read (it falls back to rendering the message body), so the builders below emit
* only the nested form while the parsers still accept the flat one for history already
* stored on the server.
*/
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function readNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}

export function buildLocationMetadata(latitude: number, longitude: number, label?: string): string {
return JSON.stringify({ location: { latitude, longitude, label } });
}

export function buildGifMetadata(gif: { GifUrl: string; PreviewUrl?: string; Width?: number; Height?: number }): string {
return JSON.stringify({ gif: { url: gif.GifUrl, previewUrl: gif.PreviewUrl, width: gif.Width, height: gif.Height } });
}

export function parseLocationMetadata(metadataJson?: string | null): ChatLocationMetadata | null {
return parseMetadata<ChatLocationMetadata>(metadataJson);
const raw = parseMetadata<Record<string, unknown>>(metadataJson);
if (!raw) return null;
const nested = (raw.location ?? raw.Location) as Record<string, unknown> | undefined;
const source = nested ?? raw;
const latitude = readNumber(source.latitude ?? source.Latitude);
const longitude = readNumber(source.longitude ?? source.Longitude);
if (latitude === undefined || longitude === undefined) return null;
return { Latitude: latitude, Longitude: longitude, Label: readString(source.label ?? source.Label) };
}

export function parseGifMetadata(metadataJson?: string | null): ChatGifMetadata | null {
return parseMetadata<ChatGifMetadata>(metadataJson);
const raw = parseMetadata<Record<string, unknown>>(metadataJson);
if (!raw) return null;
const nested = (raw.gif ?? raw.Gif) as Record<string, unknown> | undefined;
const source = nested ?? raw;
const url = readString(source.url ?? source.Url ?? source.gifUrl ?? source.GifUrl);
if (!url) return null;
return {
GifUrl: url,
PreviewUrl: readString(source.previewUrl ?? source.PreviewUrl),
Width: readNumber(source.width ?? source.Width),
Height: readNumber(source.height ?? source.Height),
Title: readString(source.title ?? source.Title),
};
Comment on lines +83 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/components/chat/chat-utils.ts --items all
ast-grep outline src/components/chat/__tests__/chat-utils.test.ts --items all

rg -n -C 3 \
  'build(Location|Gif)Metadata|parse(Location|Gif)Metadata|legacy|NaN|Infinity|MetadataJson' \
  src/components/chat/__tests__/chat-utils.test.ts

Repository: Resgrid/Unit

Length of output: 2791


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== chat-utils.ts relevant code =="
sed -n '50,122p' src/components/chat/chat-utils.ts | cat -n -v

echo
echo "== chat-utils test imports and content =="
sed -n '1,140p' src/components/chat/__tests__/chat-utils.test.ts | cat -n -v

echo
echo "== metadata metadata contract tests across repo =="
rg -n -C 4 \
  'buildLocationMetadata|buildGifMetadata|parseLocationMetadata|parseGifMetadata|parseImageMetadata|parseMetadata|buildImageMetadata' \
  src/components/chat/__tests__ src --glob '*.{test,Ts,ts,tsx}' || true

echo
echo "== tests using readNumber/readString/error/null return edge cases =="
rg -n -C 3 \
  'NaN|Infinity|malform|invalid.coordinates|missing|metadataJson|MetadataJson|readNumber|readString' \
  src/components/chat/__tests__ src --glob '*.{test,Ts,ts,tsx}' || true

Repository: Resgrid/Unit

Length of output: 50368


Add Jest coverage for the metadata wire contract.

buildLocationMetadata, buildGifMetadata, parseLocationMetadata, and parseGifMetadata handle persisted message metadata and web-client compatibility, but src/components/chat/__tests__/chat-utils.test.ts does not cover these functions. Add cases for nested JSON output, legacy flat PascalCase input, malformed JSON, invalid coordinates such as NaN/Infinity, and missing GIF URL fields.

🤖 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/components/chat/chat-utils.ts` around lines 83 - 115, Add Jest coverage
in chat-utils.test.ts for buildLocationMetadata and buildGifMetadata nested JSON
output, and for both parsers handling legacy flat PascalCase input, malformed
JSON, invalid NaN/Infinity coordinates, and missing GIF URL fields. Assert valid
results and null returns according to the existing parseLocationMetadata and
parseGifMetadata behavior without changing production code.

Source: Coding guidelines

}

export function parseImageMetadata(metadataJson?: string | null): ChatImageMetadata | null {
Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/message-actions-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface MessageActionsSheetProps {
onClose: () => void;
isOwn: boolean;
isModerator: boolean;
/** Assistant conversations: no reactions, threads or deletes — copy, edit own, pin and flag stay. */
/** Assistant conversations: no reactions, threads, deletes or edits — copy, pin and flag stay. */
assistant?: boolean;
onReact: (message: ChatMessageResultData, emoji: string) => void;
onReply: (message: ChatMessageResultData) => void;
Expand Down Expand Up @@ -118,7 +118,7 @@ export function MessageActionsSheet({ message, isOpen, onClose, isOwn, isModerat
</ActionsheetItem>
) : null}

{isOwn && isText && !isDeleted ? (
{isOwn && isText && !isDeleted && !assistant ? (
<ActionsheetItem
onPress={() => {
onEdit(message);
Expand Down
19 changes: 8 additions & 11 deletions src/components/chat/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Linking } from 'react-native';

import { getChatAttachmentImageSource } from '@/api/chat/chat';
import { Avatar, AvatarFallbackText, AvatarImage } from '@/components/ui/avatar';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import { Box } from '@/components/ui/box';
import { HStack } from '@/components/ui/hstack';
import { Pressable } from '@/components/ui/pressable';
Expand All @@ -30,9 +30,11 @@ interface MessageBubbleProps {
export function MessageBubble({ message, isOwn, showSender, currentUserId, onLongPress, onToggleReaction, onOpenThread, onRetry, onPressImage }: MessageBubbleProps) {
const { t } = useTranslation();

// Realtime payloads omit empty collections; the store normalizes them, but messages
// persisted before that normalization existed can still come back without them.
const groupedReactions = useMemo(() => {
const map = new Map<string, { count: number; mine: boolean }>();
for (const reaction of message.Reactions) {
for (const reaction of message.Reactions ?? []) {
const current = map.get(reaction.Emoji) ?? { count: 0, mine: false };
current.count += 1;
if (reaction.UserId && reaction.UserId === currentUserId) current.mine = true;
Expand Down Expand Up @@ -65,7 +67,7 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon
}

if (message.MessageType === ChatMessageType.Image) {
const attachment = message.Attachments[0];
const attachment = (message.Attachments ?? [])[0];
const uri = message._localAttachmentUri ?? (attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId).uri : undefined);
const source = attachment ? getChatAttachmentImageSource(attachment.ChatAttachmentId) : uri ? { uri } : undefined;
if (!source) return <Text className={textTone}>{message.Body}</Text>;
Expand Down Expand Up @@ -121,14 +123,9 @@ export function MessageBubble({ message, isOwn, showSender, currentUserId, onLon

return (
<HStack className={`my-1 px-3 ${isOwn ? 'justify-end' : 'justify-start'}`} space="sm">
{!isOwn && showSender ? (
<Avatar size="sm">
<AvatarFallbackText>{message.SenderDisplayName ?? '?'}</AvatarFallbackText>
{message.SenderUserId ? <AvatarImage source={{ uri: getPersonAvatarUrl(message.SenderUserId) ?? '' }} /> : null}
</Avatar>
) : !isOwn ? (
<Box className="w-8" />
) : null}
{/* No initials fallback: the avatar endpoint always answers with a silhouette
placeholder rather than a 404, so initials would never be visible anyway. */}
{!isOwn && showSender ? <Avatar size="sm">{message.SenderUserId ? <AvatarImage source={{ uri: getPersonAvatarUrl(message.SenderUserId) ?? '' }} /> : null}</Avatar> : !isOwn ? <Box className="w-8" /> : null}

<VStack className={`max-w-[78%] ${isOwn ? 'items-end' : 'items-start'}`} space="xs">
{!isOwn && showSender && message.SenderDisplayName ? <Text className="ml-1 text-xs font-medium text-typography-500">{message.SenderDisplayName}</Text> : null}
Expand Down
3 changes: 1 addition & 2 deletions src/components/chat/new-conversation-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { createAdHocChannel, createDirectMessage } from '@/api/chat/chat';
import { getRecipients } from '@/api/messaging/messages';
import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet';
import { Avatar, AvatarFallbackText, AvatarImage } from '@/components/ui/avatar';
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import { Box } from '@/components/ui/box';
import { Button, ButtonText } from '@/components/ui/button';
import { Center } from '@/components/ui/center';
Expand Down Expand Up @@ -172,7 +172,6 @@ export function NewConversationSheet({ isOpen, onClose, mode, onCreated }: NewCo
<HStack className="items-center justify-between">
<HStack className="flex-1 items-center" space="sm">
<Avatar size="sm">
<AvatarFallbackText>{recipient.Name}</AvatarFallbackText>
<AvatarImage source={{ uri: getAvatarUrl(userId) }} />
</Avatar>
<Text className="flex-1 text-typography-900" numberOfLines={1}>
Expand Down
Loading
Loading