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
91 changes: 85 additions & 6 deletions app/src/__tests__/utils/formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
formatNumber,
formatDateForLocale,
getTime,
getLocalTime,
FUTURE,
PAST,
getUtcStartOfDay,
Expand Down Expand Up @@ -198,21 +199,37 @@ describe('formatDateForLocale', () => {
});

describe('getTime', () => {
describe('valid ISO dates (UTC)', () => {
describe('valid ISO dates (UTC time)', () => {
it('returns correct UTC time (HH:mm)', () => {
expect(getTime('2026-02-17T09:55:24.190Z')).toBe('09:55');
const testDate = '2026-02-17T09:55:24.190Z';
const date = new Date(testDate);
const expectedHours = date.getUTCHours().toString().padStart(2, '0');
const expectedMinutes = date.getUTCMinutes().toString().padStart(2, '0');
expect(getTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('pads single digit hours and minutes', () => {
expect(getTime('2026-02-17T05:07:00.000Z')).toBe('05:07');
const testDate = '2026-02-17T05:07:00.000Z';
const date = new Date(testDate);
const expectedHours = date.getUTCHours().toString().padStart(2, '0');
const expectedMinutes = date.getUTCMinutes().toString().padStart(2, '0');
expect(getTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('handles midnight correctly', () => {
expect(getTime('2026-02-17T00:00:00.000Z')).toBe('00:00');
const testDate = '2026-02-17T00:00:00.000Z';
const date = new Date(testDate);
const expectedHours = date.getUTCHours().toString().padStart(2, '0');
const expectedMinutes = date.getUTCMinutes().toString().padStart(2, '0');
expect(getTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('handles end of day correctly', () => {
expect(getTime('2026-02-17T23:59:59.999Z')).toBe('23:59');
const testDate = '2026-02-17T23:59:59.999Z';
const date = new Date(testDate);
const expectedHours = date.getUTCHours().toString().padStart(2, '0');
const expectedMinutes = date.getUTCMinutes().toString().padStart(2, '0');
expect(getTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});
});

Expand All @@ -239,6 +256,64 @@ describe('getTime', () => {
});
});

describe('getLocalTime', () => {
describe('valid ISO dates (local time)', () => {
it('returns correct local time (HH:mm)', () => {
const testDate = '2026-02-17T09:55:24.190Z';
const date = new Date(testDate);
const expectedHours = date.getHours().toString().padStart(2, '0');
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('pads single digit hours and minutes', () => {
const testDate = '2026-02-17T05:07:00.000Z';
const date = new Date(testDate);
const expectedHours = date.getHours().toString().padStart(2, '0');
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('handles midnight correctly', () => {
const testDate = '2026-02-17T00:00:00.000Z';
const date = new Date(testDate);
const expectedHours = date.getHours().toString().padStart(2, '0');
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});

it('handles end of day correctly', () => {
const testDate = '2026-02-17T23:59:59.999Z';
const date = new Date(testDate);
const expectedHours = date.getHours().toString().padStart(2, '0');
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
});
});

describe('invalid input', () => {
it('returns EMPTY_STRING for empty string', () => {
expect(getLocalTime('')).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING for invalid date string', () => {
expect(getLocalTime('not-a-date')).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING for malformed ISO string', () => {
expect(getLocalTime('2026-99-99T99:99:99Z')).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING for null (runtime edge case)', () => {
expect(getLocalTime(null as unknown as string)).toBe(EMPTY_STRING);
});

it('returns EMPTY_STRING for undefined (runtime edge case)', () => {
expect(getLocalTime(undefined as unknown as string)).toBe(EMPTY_STRING);
});
});
});

describe('Date Utilities', () => {
describe('getUtcStartOfDay', () => {
it('should return UTC midnight for a given date', () => {
Expand Down Expand Up @@ -428,8 +503,12 @@ describe('formatDateForChat', () => {
describe('within last 24 hours', () => {
it('returns time (HH:mm)', () => {
const iso = '2026-03-10T09:30:00Z';
const date = new Date(iso);
const expectedHours = date.getHours().toString().padStart(2, '0');
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
const expectedTime = `${expectedHours}:${expectedMinutes}`;

expect(formatDateForChat(iso, locale)).toBe('09:30');
expect(formatDateForChat(iso, locale)).toBe(expectedTime);
});
});

Expand Down
4 changes: 2 additions & 2 deletions app/src/components/chat/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Avatar from '@/components/avatar/Avatar';
import { chatMessageStyle } from '@/styles/components';
import { Color } from '@/styles/tokens';
import type { Message, MessageType } from '@/types/chat';
import { formatDateForChat, getTime } from '@/utils/formatting';
import { formatDateForChat, getLocalTime } from '@/utils/formatting';

type Props = {
message: Message;
Expand All @@ -19,7 +19,7 @@ const ChatMessage = ({ message, currentUserId, locale }: Props) => {
const isOwn = message.identity.id === currentUserId;
const type: MessageType = isOwn ? 'own' : 'other';
const messageDate = formatDateForChat(message.audit?.created?.at, locale);
const messageTime = getTime(message.audit?.created?.at || '');
const messageTime = getLocalTime(message.audit?.created?.at || '');

const styles = useMemo(
() =>
Expand Down
13 changes: 8 additions & 5 deletions app/src/components/details/DetailsHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { View, Text, StyleSheet } from 'react-native';

import AvatarIcon from '@/components/avatar/Avatar';
import GroupAvatar from '@/components/avatar/GroupAvatar';
import Chip from '@/components/chip/Chip';
import { statusList } from '@/constants/status';
import { listItemStyle } from '@/styles';
Expand All @@ -18,11 +19,11 @@ const DetailsHeader = ({
variant = 'default',
headerTitleTestId,
headerStatusTestId,
avatars,
}: ListItemWithStatusProps) => {
const { t } = useTranslation();

const hasSubtitle = Boolean(subtitle);
const hasImage = Boolean(imagePath);
const status = getStatus(statusText, statusList);

const styles = useMemo(
Expand All @@ -42,11 +43,13 @@ const DetailsHeader = ({
return (
<View style={styles.screenHeader}>
<View style={styles.topRow}>
{hasImage && (
<View style={styles.avatarWrapper}>
<View style={styles.avatarWrapper}>
{avatars && avatars.length > 0 ? (
<GroupAvatar avatars={avatars} size={44} />
) : (
<AvatarIcon id={id} imagePath={imagePath} size={44} />
</View>
)}
)}
</View>
<View style={styles.textWrapper}>
{hasSubtitle && (
<Text style={styles.title} numberOfLines={1} testID={headerTitleTestId}>
Expand Down
18 changes: 18 additions & 0 deletions app/src/hooks/queries/useChatData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';

import { useChatApi } from '@/services/chatService';

export const useChatData = (chatId: string | undefined) => {
const { getChat } = useChatApi();

return useQuery({
queryKey: ['chat', chatId],
queryFn: () => {
if (!chatId) {
throw new Error('chatId is required for fetching chat');
}
return getChat(chatId);
},
enabled: !!chatId,
});
};
75 changes: 61 additions & 14 deletions app/src/screens/chat/ChatConversationScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRoute } from '@react-navigation/native';
import type { RouteProp } from '@react-navigation/native';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Expand All @@ -14,17 +14,24 @@ import ChatConversationFooter from '@/components/chat/ChatConversationFooter';
import ChatMessage from '@/components/chat/ChatMessage';
import StatusMessage from '@/components/common/EmptyStateHelper';
import DetailsHeader from '@/components/details/DetailsHeader';
import { EMPTY_VALUE } from '@/constants/common';
import { useAccount } from '@/context/AccountContext';
import { useMessages, MessagesProvider } from '@/context/MessagesContext';
import { useChatData } from '@/hooks/queries/useChatData';
import { screenStyle } from '@/styles';
import type { Message } from '@/types/chat';
import type { RootStackParamList } from '@/types/navigation';
import { mapToChatListItemProps } from '@/utils/chat';
import { TestIDs } from '@/utils/testID';

const SCROLL_DELAY_MS = 200;
const SCROLL_TO_NEWEST_DELAY_MS = 200;
const KEYBOARD_VERTICAL_OFFSET = 100;
const LOAD_MORE_THRESHOLD = 0.5;

const ChatConversationScreenContent = () => {
const [inputText, setInputText] = useState('');
const [contentHeight, setContentHeight] = useState(0);
const [layoutHeight, setLayoutHeight] = useState(0);
const { i18n, t } = useTranslation();
const flatListRef = useRef<FlatList<Message>>(null);
const previousFirstMessageIdRef = useRef<string | null>(null);
Expand All @@ -42,6 +49,18 @@ const ChatConversationScreenContent = () => {
chatId,
} = useMessages();

const { data: chatData } = useChatData(chatId);

const chatProps = useMemo(
() => (chatData ? mapToChatListItemProps(chatData, i18n.language, currentUserId) : null),
[chatData, i18n.language, currentUserId],
);

const otherParticipant =
chatData?.type === 'Direct'
? chatData.participants?.find((p) => p.identity.id !== currentUserId)
: null;

const handleScrollToIndexFailed = useCallback(() => {
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, []);
Expand All @@ -58,7 +77,7 @@ const ChatConversationScreenContent = () => {
} catch (error) {
handleScrollToIndexFailed();
}
}, SCROLL_DELAY_MS);
}, SCROLL_TO_NEWEST_DELAY_MS);
}, [messages.length, handleScrollToIndexFailed]);

useEffect(() => {
Expand Down Expand Up @@ -87,18 +106,39 @@ const ChatConversationScreenContent = () => {
setInputText('');
};

const handleContentSizeChange = useCallback((_width: number, height: number) => {
setContentHeight(height);
}, []);

const handleLayout = useCallback((event: { nativeEvent: { layout: { height: number } } }) => {
setLayoutHeight(event.nativeEvent.layout.height);
}, []);

const contentFillsScreen = contentHeight > layoutHeight;

const displayMessages = useMemo(
() => (contentFillsScreen ? messages : [...messages].reverse()),
[messages, contentFillsScreen],
);

const contentContainerStyle = useMemo(
() => (contentFillsScreen ? undefined : screenStyle.contentContainerTop),
[contentFillsScreen],
);

return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={100}
keyboardVerticalOffset={KEYBOARD_VERTICAL_OFFSET}
>
<DetailsHeader
id={chatId ?? ''}
title="Chat"
id={otherParticipant?.identity.id ?? chatId ?? ''}
title={chatProps?.title ?? EMPTY_VALUE}
subtitle={chatId ?? ''}
statusText=""
imagePath=""
imagePath={otherParticipant?.identity.icon ?? ''}
avatars={chatProps?.avatars}
variant="chat"
/>
<StatusMessage
Expand All @@ -115,22 +155,29 @@ const ChatConversationScreenContent = () => {
<FlatList
ref={flatListRef}
style={styles.flatList}
data={messages}
extraData={messages}
inverted
contentContainerStyle={contentContainerStyle}
data={displayMessages}
extraData={displayMessages}
inverted={contentFillsScreen}
keyExtractor={(item) => item.id}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => (
<ChatMessage message={item} currentUserId={currentUserId} locale={i18n.language} />
)}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
onEndReachedThreshold={LOAD_MORE_THRESHOLD}
onScrollToIndexFailed={handleScrollToIndexFailed}
onContentSizeChange={handleContentSizeChange}
onLayout={handleLayout}
ListHeaderComponent={messagesFetchingNext ? <ActivityIndicator /> : null}
showsVerticalScrollIndicator={false}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
maintainVisibleContentPosition={
contentFillsScreen
? {
minIndexForVisible: 0,
}
: undefined
}
/>
</StatusMessage>
<ChatConversationFooter value={inputText} onChangeText={setInputText} onSend={sendMessage} />
Expand Down
13 changes: 10 additions & 3 deletions app/src/services/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ export function useChatApi() {
`&offset=${offset}` +
`&limit=${limit}`;

logger.debug('[ChatService] Fetching chats', { endpoint, offset, limit, userId });

const response = await api.get<PaginatedResponse<ChatItem>>(endpoint);

logger.debug('[ChatService] Chats fetched', {
Expand All @@ -37,10 +35,19 @@ export function useChatApi() {
[api],
);

const getChat = useCallback(
async (chatId: string): Promise<ChatItem> => {
const endpoint = `/v1/helpdesk/chats/${chatId}?select=participants`;
return await api.get<ChatItem>(endpoint);
},
[api],
);

return useMemo(
() => ({
getChats,
getChat,
}),
[getChats],
[getChats, getChat],
);
}
4 changes: 4 additions & 0 deletions app/src/styles/components/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const screenStyle = {
contentFillContainer: {
flexGrow: 1,
},
contentContainerTop: {
flexGrow: 1,
justifyContent: 'flex-start',
},
containerFlex: {
flex: 1,
},
Expand Down
Loading
Loading