@@ -39,52 +39,71 @@ import androidx.compose.runtime.LaunchedEffect
3939import androidx.compose.runtime.getValue
4040import androidx.compose.runtime.mutableStateOf
4141import androidx.compose.runtime.remember
42- import androidx.compose.runtime.rememberCoroutineScope
4342import androidx.compose.runtime.setValue
43+ import androidx.compose.runtime.snapshotFlow
4444import androidx.compose.ui.Alignment
4545import androidx.compose.ui.Modifier
4646import androidx.compose.ui.platform.LocalResources
4747import androidx.compose.ui.res.stringResource
48+ import androidx.compose.ui.semantics.clearAndSetSemantics
49+ import androidx.compose.ui.semantics.contentDescription
50+ import androidx.compose.ui.semantics.heading
51+ import androidx.compose.ui.semantics.semantics
4852import androidx.compose.ui.text.font.FontWeight
4953import androidx.compose.ui.text.input.KeyboardCapitalization
5054import androidx.compose.ui.text.style.TextAlign
5155import androidx.compose.ui.tooling.preview.Preview
5256import androidx.compose.ui.unit.dp
53- import kotlinx.coroutines.launch
5457import org.wordpress.android.R
5558import org.wordpress.android.support.aibot.model.BotConversation
5659import org.wordpress.android.support.aibot.model.BotMessage
5760import org.wordpress.android.support.aibot.util.formatRelativeTime
5861import org.wordpress.android.support.aibot.util.generateSampleBotConversations
5962import org.wordpress.android.ui.compose.theme.AppThemeM3
6063
64+ private const val PAGINATION_TRIGGER_THRESHOLD = 4
65+
6166@OptIn(ExperimentalMaterial3Api ::class )
6267@Composable
6368fun AIBotConversationDetailScreen (
6469 snackbarHostState : SnackbarHostState ,
6570 conversation : BotConversation ,
6671 isLoading : Boolean ,
6772 isBotTyping : Boolean ,
73+ isLoadingOlderMessages : Boolean ,
74+ hasMorePages : Boolean ,
6875 canSendMessage : Boolean ,
6976 userName : String ,
7077 onBackClick : () -> Unit ,
71- onSendMessage : (String ) -> Unit
78+ onSendMessage : (String ) -> Unit ,
79+ onLoadOlderMessages : () -> Unit
7280) {
7381 var messageText by remember { mutableStateOf(" " ) }
7482 val listState = rememberLazyListState()
75- val coroutineScope = rememberCoroutineScope()
76-
77- // Scroll to bottom when conversation changes or messages are added or typing state changes
78- LaunchedEffect (conversation.id, conversation.messages.size, isBotTyping) {
79- if (conversation.messages.isNotEmpty() || isBotTyping) {
80- coroutineScope.launch {
81- // +2 for welcome header and spacer, +1 if typing indicator is showing
82- val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0
83- listState.animateScrollToItem(itemCount)
84- }
83+
84+ // Scroll to bottom when new messages are added at the end (not when loading older messages at the beginning)
85+ // Only scroll to bottom when:
86+ // 1. The last message changes (new message added at the end)
87+ // 2. Bot starts typing
88+ // 3. We're not loading older messages (which adds messages at the beginning)
89+ LaunchedEffect (conversation.id, conversation.messages.lastOrNull()?.id, isBotTyping) {
90+ if ((conversation.messages.isNotEmpty() || isBotTyping) && ! isLoadingOlderMessages) {
91+ listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1 )
8592 }
8693 }
8794
95+ // Detect when user scrolls near the top to load older messages
96+ LaunchedEffect (listState, isLoadingOlderMessages, isLoading, hasMorePages) {
97+ snapshotFlow { listState.firstVisibleItemIndex }
98+ .collect { firstVisibleIndex ->
99+ val shouldLoadMore = ! isLoadingOlderMessages && firstVisibleIndex <= PAGINATION_TRIGGER_THRESHOLD
100+
101+ if (shouldLoadMore && ! isLoading && hasMorePages) {
102+ onLoadOlderMessages()
103+ }
104+ }
105+ }
106+
88107 val resources = LocalResources .current
89108
90109 Scaffold (
@@ -128,8 +147,25 @@ fun AIBotConversationDetailScreen(
128147 state = listState,
129148 verticalArrangement = Arrangement .spacedBy(12 .dp)
130149 ) {
131- item {
132- WelcomeHeader (userName)
150+ // Show loading indicator at top when loading older messages
151+ if (isLoadingOlderMessages) {
152+ item {
153+ Box (
154+ modifier = Modifier
155+ .fillMaxWidth()
156+ .padding(vertical = 16 .dp),
157+ contentAlignment = Alignment .Center
158+ ) {
159+ CircularProgressIndicator ()
160+ }
161+ }
162+ }
163+
164+ // Only show welcome header when we're at the beginning (no more pages to load)
165+ if (! hasMorePages) {
166+ item {
167+ WelcomeHeader (userName)
168+ }
133169 }
134170
135171 // Key ensures the items recompose when messages change
@@ -163,10 +199,17 @@ fun AIBotConversationDetailScreen(
163199
164200@Composable
165201private fun WelcomeHeader (userName : String ) {
202+ val greeting = stringResource(R .string.ai_bot_welcome_greeting, userName)
203+ val message = stringResource(R .string.ai_bot_welcome_message)
204+ val welcomeDescription = " $greeting . $message "
205+
166206 Card (
167207 modifier = Modifier
168208 .fillMaxWidth()
169- .padding(vertical = 8 .dp),
209+ .padding(vertical = 8 .dp)
210+ .clearAndSetSemantics {
211+ contentDescription = welcomeDescription
212+ },
170213 colors = CardDefaults .cardColors(
171214 containerColor = MaterialTheme .colorScheme.surface
172215 ),
@@ -188,7 +231,8 @@ private fun WelcomeHeader(userName: String) {
188231 text = stringResource(R .string.ai_bot_welcome_greeting, userName),
189232 style = MaterialTheme .typography.titleLarge,
190233 fontWeight = FontWeight .Bold ,
191- color = MaterialTheme .colorScheme.primary
234+ color = MaterialTheme .colorScheme.primary,
235+ modifier = Modifier .semantics { heading() }
192236 )
193237
194238 Text (
@@ -209,6 +253,7 @@ private fun ChatInputBar(
209253 onSendClick : () -> Unit
210254) {
211255 val canSend = messageText.isNotBlank() && canSendMessage
256+ val messageInputLabel = stringResource(R .string.ai_bot_message_input_placeholder)
212257
213258 Row (
214259 modifier = Modifier
@@ -221,8 +266,10 @@ private fun ChatInputBar(
221266 OutlinedTextField (
222267 value = messageText,
223268 onValueChange = onMessageTextChange,
224- modifier = Modifier .weight(1f ),
225- placeholder = { Text (stringResource(R .string.ai_bot_message_input_placeholder)) },
269+ modifier = Modifier
270+ .weight(1f )
271+ .semantics { contentDescription = messageInputLabel },
272+ placeholder = { Text (messageInputLabel) },
226273 maxLines = 4 ,
227274 keyboardOptions = KeyboardOptions (capitalization = KeyboardCapitalization .Sentences )
228275 )
@@ -246,6 +293,10 @@ private fun ChatInputBar(
246293
247294@Composable
248295private fun MessageBubble (message : BotMessage , resources : android.content.res.Resources ) {
296+ val timestamp = formatRelativeTime(message.date, resources)
297+ val author = stringResource(if (message.isWrittenByUser) R .string.ai_bot_you else R .string.ai_bot_support_bot)
298+ val messageDescription = " $author , $timestamp . ${message.formattedText} "
299+
249300 Row (
250301 modifier = Modifier .fillMaxWidth(),
251302 horizontalArrangement = if (message.isWrittenByUser) {
@@ -271,6 +322,9 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re
271322 )
272323 )
273324 .padding(12 .dp)
325+ .clearAndSetSemantics {
326+ contentDescription = messageDescription
327+ }
274328 ) {
275329 Column {
276330 Text (
@@ -287,7 +341,7 @@ private fun MessageBubble(message: BotMessage, resources: android.content.res.Re
287341 Spacer (modifier = Modifier .height(4 .dp))
288342
289343 Text (
290- text = formatRelativeTime(message.date, resources) ,
344+ text = timestamp ,
291345 style = MaterialTheme .typography.bodySmall,
292346 color = if (message.isWrittenByUser) {
293347 MaterialTheme .colorScheme.onPrimaryContainer.copy(alpha = 0.7f )
@@ -318,6 +372,7 @@ private fun TypingIndicatorBubble() {
318372 )
319373 )
320374 .padding(16 .dp)
375+ .semantics { contentDescription = " AI Bot is typing" }
321376 ) {
322377 Row (
323378 horizontalArrangement = Arrangement .spacedBy(4 .dp),
@@ -369,9 +424,12 @@ private fun ConversationDetailScreenPreview() {
369424 conversation = sampleConversation,
370425 isLoading = false ,
371426 isBotTyping = false ,
427+ isLoadingOlderMessages = false ,
428+ hasMorePages = false ,
372429 canSendMessage = true ,
373430 onBackClick = { },
374- onSendMessage = { }
431+ onSendMessage = { },
432+ onLoadOlderMessages = { }
375433 )
376434 }
377435}
@@ -389,9 +447,12 @@ private fun ConversationDetailScreenPreviewDark() {
389447 conversation = sampleConversation,
390448 isLoading = false ,
391449 isBotTyping = false ,
450+ isLoadingOlderMessages = false ,
451+ hasMorePages = false ,
392452 canSendMessage = true ,
393453 onBackClick = { },
394- onSendMessage = { }
454+ onSendMessage = { },
455+ onLoadOlderMessages = { }
395456 )
396457 }
397458}
@@ -409,9 +470,12 @@ private fun ConversationDetailScreenWordPressPreview() {
409470 conversation = sampleConversation,
410471 isLoading = false ,
411472 isBotTyping = false ,
473+ isLoadingOlderMessages = false ,
474+ hasMorePages = false ,
412475 canSendMessage = true ,
413476 onBackClick = { },
414- onSendMessage = { }
477+ onSendMessage = { },
478+ onLoadOlderMessages = { }
415479 )
416480 }
417481}
@@ -429,9 +493,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() {
429493 conversation = sampleConversation,
430494 isLoading = false ,
431495 isBotTyping = false ,
496+ isLoadingOlderMessages = false ,
497+ hasMorePages = false ,
432498 canSendMessage = true ,
433499 onBackClick = { },
434- onSendMessage = { }
500+ onSendMessage = { },
501+ onLoadOlderMessages = { }
435502 )
436503 }
437504}
0 commit comments