Skip to content

Commit d0122ff

Browse files
authored
CMM-883 support Odie bot conversation pagination (#22316)
* Support pagination * Triggering in the 4th element * detekt * TODO for debug purposes * Claude PR suggestions Mutex and constant * Support pagination * Triggering in the 4th element * detekt * TODO for debug purposes * Claude PR suggestions Mutex and constant * Detekt * Removing testing code * CMM-894 new support general improvements (#22320) * Put ConversationListView in common between bots and HE * Empty and error state * Skip site capitalization * Adding a11c labels * Adding headings labels * adding accessible labels to chat bubbles * detekt * Fixing tests * PR suggestion about bot chat bubble * Fixing tests * Fixing TalkBack duplication
1 parent fd316a5 commit d0122ff

20 files changed

+622
-292
lines changed

WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import uniffi.wp_api.AddMessageToBotConversationParams
1515
import uniffi.wp_api.BotConversationSummary
1616
import uniffi.wp_api.CreateBotConversationParams
1717
import uniffi.wp_api.GetBotConversationParams
18+
import java.util.Date
1819
import javax.inject.Inject
1920
import javax.inject.Named
2021

2122
private const val BOT_ID = "jetpack-chat-mobile"
23+
private const val ITEMS_PER_PAGE = 20
2224

2325
class AIBotSupportRepository @Inject constructor(
2426
private val appLogWrapper: AppLogWrapper,
@@ -67,12 +69,15 @@ class AIBotSupportRepository @Inject constructor(
6769
}
6870
}
6971

70-
suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) {
72+
suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) {
7173
val response = wpComApiClient.request { requestBuilder ->
7274
requestBuilder.supportBots().getBotConversation(
7375
botId = BOT_ID,
7476
chatId = chatId.toULong(),
75-
params = GetBotConversationParams()
77+
params = GetBotConversationParams(
78+
pageNumber = pageNumber.toULong(),
79+
itemsPerPage = ITEMS_PER_PAGE.toULong()
80+
)
7681
)
7782
}
7883
when (response) {
@@ -158,8 +163,8 @@ class AIBotSupportRepository @Inject constructor(
158163
BotConversation (
159164
id = chatId.toLong(),
160165
createdAt = createdAt,
161-
mostRecentMessageDate = messages.last().createdAt,
162-
lastMessage = messages.last().content,
166+
mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(),
167+
lastMessage = messages.lastOrNull()?.content.orEmpty(),
163168
messages = messages.map { it.toBotMessage() }
164169
)
165170

WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotConversationDetailScreen.kt

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,52 +39,71 @@ import androidx.compose.runtime.LaunchedEffect
3939
import androidx.compose.runtime.getValue
4040
import androidx.compose.runtime.mutableStateOf
4141
import androidx.compose.runtime.remember
42-
import androidx.compose.runtime.rememberCoroutineScope
4342
import androidx.compose.runtime.setValue
43+
import androidx.compose.runtime.snapshotFlow
4444
import androidx.compose.ui.Alignment
4545
import androidx.compose.ui.Modifier
4646
import androidx.compose.ui.platform.LocalResources
4747
import 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
4852
import androidx.compose.ui.text.font.FontWeight
4953
import androidx.compose.ui.text.input.KeyboardCapitalization
5054
import androidx.compose.ui.text.style.TextAlign
5155
import androidx.compose.ui.tooling.preview.Preview
5256
import androidx.compose.ui.unit.dp
53-
import kotlinx.coroutines.launch
5457
import org.wordpress.android.R
5558
import org.wordpress.android.support.aibot.model.BotConversation
5659
import org.wordpress.android.support.aibot.model.BotMessage
5760
import org.wordpress.android.support.aibot.util.formatRelativeTime
5861
import org.wordpress.android.support.aibot.util.generateSampleBotConversations
5962
import org.wordpress.android.ui.compose.theme.AppThemeM3
6063

64+
private const val PAGINATION_TRIGGER_THRESHOLD = 4
65+
6166
@OptIn(ExperimentalMaterial3Api::class)
6267
@Composable
6368
fun 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
165201
private 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
248295
private 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

Comments
 (0)