Skip to content

Commit 7e32e1a

Browse files
authored
feat(chat): messages scroll fix + chat title [AI] (#237)
* adapt chat data to title and group title * display local time on chat messages * switch chat messages positions depend on block message length * fixes * add getLocalTme
1 parent e3b39aa commit 7e32e1a

File tree

10 files changed

+217
-40
lines changed

10 files changed

+217
-40
lines changed

app/src/__tests__/utils/formatting.test.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatNumber,
66
formatDateForLocale,
77
getTime,
8+
getLocalTime,
89
FUTURE,
910
PAST,
1011
getUtcStartOfDay,
@@ -198,21 +199,37 @@ describe('formatDateForLocale', () => {
198199
});
199200

200201
describe('getTime', () => {
201-
describe('valid ISO dates (UTC)', () => {
202+
describe('valid ISO dates (UTC time)', () => {
202203
it('returns correct UTC time (HH:mm)', () => {
203-
expect(getTime('2026-02-17T09:55:24.190Z')).toBe('09:55');
204+
const testDate = '2026-02-17T09:55:24.190Z';
205+
const date = new Date(testDate);
206+
const expectedHours = date.getUTCHours().toString().padStart(2, '0');
207+
const expectedMinutes = date.getUTCMinutes().toString().padStart(2, '0');
208+
expect(getTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
204209
});
205210

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

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

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

@@ -239,6 +256,64 @@ describe('getTime', () => {
239256
});
240257
});
241258

259+
describe('getLocalTime', () => {
260+
describe('valid ISO dates (local time)', () => {
261+
it('returns correct local time (HH:mm)', () => {
262+
const testDate = '2026-02-17T09:55:24.190Z';
263+
const date = new Date(testDate);
264+
const expectedHours = date.getHours().toString().padStart(2, '0');
265+
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
266+
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
267+
});
268+
269+
it('pads single digit hours and minutes', () => {
270+
const testDate = '2026-02-17T05:07:00.000Z';
271+
const date = new Date(testDate);
272+
const expectedHours = date.getHours().toString().padStart(2, '0');
273+
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
274+
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
275+
});
276+
277+
it('handles midnight correctly', () => {
278+
const testDate = '2026-02-17T00:00:00.000Z';
279+
const date = new Date(testDate);
280+
const expectedHours = date.getHours().toString().padStart(2, '0');
281+
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
282+
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
283+
});
284+
285+
it('handles end of day correctly', () => {
286+
const testDate = '2026-02-17T23:59:59.999Z';
287+
const date = new Date(testDate);
288+
const expectedHours = date.getHours().toString().padStart(2, '0');
289+
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
290+
expect(getLocalTime(testDate)).toBe(`${expectedHours}:${expectedMinutes}`);
291+
});
292+
});
293+
294+
describe('invalid input', () => {
295+
it('returns EMPTY_STRING for empty string', () => {
296+
expect(getLocalTime('')).toBe(EMPTY_STRING);
297+
});
298+
299+
it('returns EMPTY_STRING for invalid date string', () => {
300+
expect(getLocalTime('not-a-date')).toBe(EMPTY_STRING);
301+
});
302+
303+
it('returns EMPTY_STRING for malformed ISO string', () => {
304+
expect(getLocalTime('2026-99-99T99:99:99Z')).toBe(EMPTY_STRING);
305+
});
306+
307+
it('returns EMPTY_STRING for null (runtime edge case)', () => {
308+
expect(getLocalTime(null as unknown as string)).toBe(EMPTY_STRING);
309+
});
310+
311+
it('returns EMPTY_STRING for undefined (runtime edge case)', () => {
312+
expect(getLocalTime(undefined as unknown as string)).toBe(EMPTY_STRING);
313+
});
314+
});
315+
});
316+
242317
describe('Date Utilities', () => {
243318
describe('getUtcStartOfDay', () => {
244319
it('should return UTC midnight for a given date', () => {
@@ -428,8 +503,12 @@ describe('formatDateForChat', () => {
428503
describe('within last 24 hours', () => {
429504
it('returns time (HH:mm)', () => {
430505
const iso = '2026-03-10T09:30:00Z';
506+
const date = new Date(iso);
507+
const expectedHours = date.getHours().toString().padStart(2, '0');
508+
const expectedMinutes = date.getMinutes().toString().padStart(2, '0');
509+
const expectedTime = `${expectedHours}:${expectedMinutes}`;
431510

432-
expect(formatDateForChat(iso, locale)).toBe('09:30');
511+
expect(formatDateForChat(iso, locale)).toBe(expectedTime);
433512
});
434513
});
435514

app/src/components/chat/ChatMessage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Avatar from '@/components/avatar/Avatar';
77
import { chatMessageStyle } from '@/styles/components';
88
import { Color } from '@/styles/tokens';
99
import type { Message, MessageType } from '@/types/chat';
10-
import { formatDateForChat, getTime } from '@/utils/formatting';
10+
import { formatDateForChat, getLocalTime } from '@/utils/formatting';
1111

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

2424
const styles = useMemo(
2525
() =>

app/src/components/details/DetailsHeader.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
33
import { View, Text, StyleSheet } from 'react-native';
44

55
import AvatarIcon from '@/components/avatar/Avatar';
6+
import GroupAvatar from '@/components/avatar/GroupAvatar';
67
import Chip from '@/components/chip/Chip';
78
import { statusList } from '@/constants/status';
89
import { listItemStyle } from '@/styles';
@@ -18,11 +19,11 @@ const DetailsHeader = ({
1819
variant = 'default',
1920
headerTitleTestId,
2021
headerStatusTestId,
22+
avatars,
2123
}: ListItemWithStatusProps) => {
2224
const { t } = useTranslation();
2325

2426
const hasSubtitle = Boolean(subtitle);
25-
const hasImage = Boolean(imagePath);
2627
const status = getStatus(statusText, statusList);
2728

2829
const styles = useMemo(
@@ -42,11 +43,13 @@ const DetailsHeader = ({
4243
return (
4344
<View style={styles.screenHeader}>
4445
<View style={styles.topRow}>
45-
{hasImage && (
46-
<View style={styles.avatarWrapper}>
46+
<View style={styles.avatarWrapper}>
47+
{avatars && avatars.length > 0 ? (
48+
<GroupAvatar avatars={avatars} size={44} />
49+
) : (
4750
<AvatarIcon id={id} imagePath={imagePath} size={44} />
48-
</View>
49-
)}
51+
)}
52+
</View>
5053
<View style={styles.textWrapper}>
5154
{hasSubtitle && (
5255
<Text style={styles.title} numberOfLines={1} testID={headerTitleTestId}>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
3+
import { useChatApi } from '@/services/chatService';
4+
5+
export const useChatData = (chatId: string | undefined) => {
6+
const { getChat } = useChatApi();
7+
8+
return useQuery({
9+
queryKey: ['chat', chatId],
10+
queryFn: () => {
11+
if (!chatId) {
12+
throw new Error('chatId is required for fetching chat');
13+
}
14+
return getChat(chatId);
15+
},
16+
enabled: !!chatId,
17+
});
18+
};

app/src/screens/chat/ChatConversationScreen.tsx

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useRoute } from '@react-navigation/native';
22
import type { RouteProp } from '@react-navigation/native';
3-
import { useState, useRef, useEffect, useCallback } from 'react';
3+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
44
import { useTranslation } from 'react-i18next';
55
import {
66
ActivityIndicator,
@@ -14,17 +14,24 @@ import ChatConversationFooter from '@/components/chat/ChatConversationFooter';
1414
import ChatMessage from '@/components/chat/ChatMessage';
1515
import StatusMessage from '@/components/common/EmptyStateHelper';
1616
import DetailsHeader from '@/components/details/DetailsHeader';
17+
import { EMPTY_VALUE } from '@/constants/common';
1718
import { useAccount } from '@/context/AccountContext';
1819
import { useMessages, MessagesProvider } from '@/context/MessagesContext';
20+
import { useChatData } from '@/hooks/queries/useChatData';
1921
import { screenStyle } from '@/styles';
2022
import type { Message } from '@/types/chat';
2123
import type { RootStackParamList } from '@/types/navigation';
24+
import { mapToChatListItemProps } from '@/utils/chat';
2225
import { TestIDs } from '@/utils/testID';
2326

24-
const SCROLL_DELAY_MS = 200;
27+
const SCROLL_TO_NEWEST_DELAY_MS = 200;
28+
const KEYBOARD_VERTICAL_OFFSET = 100;
29+
const LOAD_MORE_THRESHOLD = 0.5;
2530

2631
const ChatConversationScreenContent = () => {
2732
const [inputText, setInputText] = useState('');
33+
const [contentHeight, setContentHeight] = useState(0);
34+
const [layoutHeight, setLayoutHeight] = useState(0);
2835
const { i18n, t } = useTranslation();
2936
const flatListRef = useRef<FlatList<Message>>(null);
3037
const previousFirstMessageIdRef = useRef<string | null>(null);
@@ -42,6 +49,18 @@ const ChatConversationScreenContent = () => {
4249
chatId,
4350
} = useMessages();
4451

52+
const { data: chatData } = useChatData(chatId);
53+
54+
const chatProps = useMemo(
55+
() => (chatData ? mapToChatListItemProps(chatData, i18n.language, currentUserId) : null),
56+
[chatData, i18n.language, currentUserId],
57+
);
58+
59+
const otherParticipant =
60+
chatData?.type === 'Direct'
61+
? chatData.participants?.find((p) => p.identity.id !== currentUserId)
62+
: null;
63+
4564
const handleScrollToIndexFailed = useCallback(() => {
4665
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
4766
}, []);
@@ -58,7 +77,7 @@ const ChatConversationScreenContent = () => {
5877
} catch (error) {
5978
handleScrollToIndexFailed();
6079
}
61-
}, SCROLL_DELAY_MS);
80+
}, SCROLL_TO_NEWEST_DELAY_MS);
6281
}, [messages.length, handleScrollToIndexFailed]);
6382

6483
useEffect(() => {
@@ -87,18 +106,39 @@ const ChatConversationScreenContent = () => {
87106
setInputText('');
88107
};
89108

109+
const handleContentSizeChange = useCallback((_width: number, height: number) => {
110+
setContentHeight(height);
111+
}, []);
112+
113+
const handleLayout = useCallback((event: { nativeEvent: { layout: { height: number } } }) => {
114+
setLayoutHeight(event.nativeEvent.layout.height);
115+
}, []);
116+
117+
const contentFillsScreen = contentHeight > layoutHeight;
118+
119+
const displayMessages = useMemo(
120+
() => (contentFillsScreen ? messages : [...messages].reverse()),
121+
[messages, contentFillsScreen],
122+
);
123+
124+
const contentContainerStyle = useMemo(
125+
() => (contentFillsScreen ? undefined : screenStyle.contentContainerTop),
126+
[contentFillsScreen],
127+
);
128+
90129
return (
91130
<KeyboardAvoidingView
92131
style={styles.container}
93132
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
94-
keyboardVerticalOffset={100}
133+
keyboardVerticalOffset={KEYBOARD_VERTICAL_OFFSET}
95134
>
96135
<DetailsHeader
97-
id={chatId ?? ''}
98-
title="Chat"
136+
id={otherParticipant?.identity.id ?? chatId ?? ''}
137+
title={chatProps?.title ?? EMPTY_VALUE}
99138
subtitle={chatId ?? ''}
100139
statusText=""
101-
imagePath=""
140+
imagePath={otherParticipant?.identity.icon ?? ''}
141+
avatars={chatProps?.avatars}
102142
variant="chat"
103143
/>
104144
<StatusMessage
@@ -115,22 +155,29 @@ const ChatConversationScreenContent = () => {
115155
<FlatList
116156
ref={flatListRef}
117157
style={styles.flatList}
118-
data={messages}
119-
extraData={messages}
120-
inverted
158+
contentContainerStyle={contentContainerStyle}
159+
data={displayMessages}
160+
extraData={displayMessages}
161+
inverted={contentFillsScreen}
121162
keyExtractor={(item) => item.id}
122163
keyboardShouldPersistTaps="handled"
123164
renderItem={({ item }) => (
124165
<ChatMessage message={item} currentUserId={currentUserId} locale={i18n.language} />
125166
)}
126167
onEndReached={handleLoadMore}
127-
onEndReachedThreshold={0.5}
168+
onEndReachedThreshold={LOAD_MORE_THRESHOLD}
128169
onScrollToIndexFailed={handleScrollToIndexFailed}
170+
onContentSizeChange={handleContentSizeChange}
171+
onLayout={handleLayout}
129172
ListHeaderComponent={messagesFetchingNext ? <ActivityIndicator /> : null}
130173
showsVerticalScrollIndicator={false}
131-
maintainVisibleContentPosition={{
132-
minIndexForVisible: 0,
133-
}}
174+
maintainVisibleContentPosition={
175+
contentFillsScreen
176+
? {
177+
minIndexForVisible: 0,
178+
}
179+
: undefined
180+
}
134181
/>
135182
</StatusMessage>
136183
<ChatConversationFooter value={inputText} onChangeText={setInputText} onSend={sendMessage} />

app/src/services/chatService.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ export function useChatApi() {
2323
`&offset=${offset}` +
2424
`&limit=${limit}`;
2525

26-
logger.debug('[ChatService] Fetching chats', { endpoint, offset, limit, userId });
27-
2826
const response = await api.get<PaginatedResponse<ChatItem>>(endpoint);
2927

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

38+
const getChat = useCallback(
39+
async (chatId: string): Promise<ChatItem> => {
40+
const endpoint = `/v1/helpdesk/chats/${chatId}?select=participants`;
41+
return await api.get<ChatItem>(endpoint);
42+
},
43+
[api],
44+
);
45+
4046
return useMemo(
4147
() => ({
4248
getChats,
49+
getChat,
4350
}),
44-
[getChats],
51+
[getChats, getChat],
4552
);
4653
}

app/src/styles/components/common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export const screenStyle = {
2929
contentFillContainer: {
3030
flexGrow: 1,
3131
},
32+
contentContainerTop: {
33+
flexGrow: 1,
34+
justifyContent: 'flex-start',
35+
},
3236
containerFlex: {
3337
flex: 1,
3438
},

0 commit comments

Comments
 (0)