Skip to content

Commit d3b46bf

Browse files
authored
CMM-884 support iteration over the whole flow and style (#22310)
* Adding basic UI * Renaming * Some styling * Renaming and dummy data * Using proper "new conversation icon" * Conversation details screen * Creating the reply bottomsheet * Linking to the support screen * bottomsheet fix * Mov navigation form activity to viewmodel * Adding create ticket screen * More screen adjustments * Extracting common code * Margin fix * detekt * Style * New ticket check * Creating tests * Creating repository and load conversations function * Adding createConversation function * Creating loadConversation func * Loading conversations form the viewmodel * Adding loading spinner * Pull to refresh * Proper ionitialization * Adding empty screen * Handling send new conversation * Show loading when sending * New ticket creation fix * Using snackbar for errors * Error handling * Answering conversation * Adding some test to the repository * More tests! * Compile fixes * Similarities improvements * Using snackbar in bots activity * Extracting EmptyConversationsView * Renaming * Extracting VM and UI common code * Extracting navigation common code * Renaming VMs for clarification * More refactor * Capitalise text fields * Updating rs library * Loading conversation UX * Style fix * Fixing scaffolds paddings * userID fix * Fixing the padding problem in bot chat when the keyboard is opened * Apply padding to create ticket screen when the keyboard is opened * Fixing scroll state in reply bottomsheet * Adding tests for the new common viewmodel * Fixing AIBotSupportViewModel tests * detekt * Improvements int he conversation interaction * Adding tests for HE VM * Saving draft state * Properly navigating when a ticket is selected * Error parsing improvement * accessToken suggestion improvements * General suggestions * Send message error UX improvement * Fixing tests * Converting the UI to more AndroidMaterial style * Bots screen renaming * Bots screens renaming * Make NewTicket screen more Android Material theme as well * Adding preview for EmptyConversationsView * Button fix * detekt * Ticket selection change * Supporting markdown text * detekt * Improving MarkdownUtils * Formatting text in the repository layer instead the ui * Renaming * Fixing tests * Parsing markdown more exhaustively * New links support * Detekt * 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 1bc9cde commit d3b46bf

33 files changed

+2063
-835
lines changed

WordPress/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ static def addBuildConfigFieldsFromPrefixedProperties(variant, properties, prefi
374374

375375
dependencies {
376376
implementation(libs.androidx.navigation.compose)
377+
implementation(libs.commonmark)
377378
compileOnly project(path: ':libs:annotations')
378379
ksp project(':libs:processors')
379380
implementation (project(path:':libs:networking')) {
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package org.wordpress.android.support.aibot.model
22

3+
import androidx.compose.runtime.Immutable
4+
import androidx.compose.ui.text.AnnotatedString
35
import java.util.Date
46

7+
@Immutable
58
data class BotMessage(
69
val id: Long,
7-
val text: String,
10+
val rawText: String,
11+
val formattedText: AnnotatedString,
812
val date: Date,
913
val isWrittenByUser: Boolean
1014
)

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@ import org.wordpress.android.modules.IO_THREAD
77
import org.wordpress.android.networking.restapi.WpComApiClientProvider
88
import org.wordpress.android.support.aibot.model.BotConversation
99
import org.wordpress.android.support.aibot.model.BotMessage
10+
import org.wordpress.android.ui.compose.utils.markdownToAnnotatedString
1011
import org.wordpress.android.util.AppLog
1112
import rs.wordpress.api.kotlin.WpComApiClient
1213
import rs.wordpress.api.kotlin.WpRequestResult
1314
import uniffi.wp_api.AddMessageToBotConversationParams
1415
import uniffi.wp_api.BotConversationSummary
1516
import uniffi.wp_api.CreateBotConversationParams
1617
import uniffi.wp_api.GetBotConversationParams
18+
import java.util.Date
1719
import javax.inject.Inject
1820
import javax.inject.Named
1921

2022
private const val BOT_ID = "jetpack-chat-mobile"
23+
private const val ITEMS_PER_PAGE = 20
2124

2225
class AIBotSupportRepository @Inject constructor(
2326
private val appLogWrapper: AppLogWrapper,
@@ -66,12 +69,15 @@ class AIBotSupportRepository @Inject constructor(
6669
}
6770
}
6871

69-
suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) {
72+
suspend fun loadConversation(chatId: Long, pageNumber: Long = 1L): BotConversation? = withContext(ioDispatcher) {
7073
val response = wpComApiClient.request { requestBuilder ->
7174
requestBuilder.supportBots().getBotConversation(
7275
botId = BOT_ID,
7376
chatId = chatId.toULong(),
74-
params = GetBotConversationParams()
77+
params = GetBotConversationParams(
78+
pageNumber = pageNumber.toULong(),
79+
itemsPerPage = ITEMS_PER_PAGE.toULong()
80+
)
7581
)
7682
}
7783
when (response) {
@@ -157,15 +163,16 @@ class AIBotSupportRepository @Inject constructor(
157163
BotConversation (
158164
id = chatId.toLong(),
159165
createdAt = createdAt,
160-
mostRecentMessageDate = messages.last().createdAt,
161-
lastMessage = messages.last().content,
166+
mostRecentMessageDate = messages.lastOrNull()?.createdAt ?: Date(),
167+
lastMessage = messages.lastOrNull()?.content.orEmpty(),
162168
messages = messages.map { it.toBotMessage() }
163169
)
164170

165171
private fun uniffi.wp_api.BotMessage.toBotMessage(): BotMessage =
166172
BotMessage(
167173
id = messageId.toLong(),
168-
text = content,
174+
rawText = content,
175+
formattedText = markdownToAnnotatedString(content),
169176
date = createdAt,
170177
isWrittenByUser = role == "user"
171178
)

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

Lines changed: 107 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.wordpress.android.support.aibot.ui
22

3+
import android.content.res.Configuration.UI_MODE_NIGHT_YES
34
import androidx.compose.foundation.background
45
import androidx.compose.foundation.layout.Arrangement
56
import androidx.compose.foundation.layout.Box
@@ -16,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn
1617
import androidx.compose.foundation.lazy.items
1718
import androidx.compose.foundation.lazy.rememberLazyListState
1819
import androidx.compose.foundation.shape.RoundedCornerShape
20+
import androidx.compose.foundation.text.KeyboardOptions
1921
import androidx.compose.material.icons.Icons
2022
import androidx.compose.material.icons.automirrored.filled.ArrowBack
2123
import androidx.compose.material.icons.automirrored.filled.Send
@@ -28,63 +30,80 @@ import androidx.compose.material3.IconButton
2830
import androidx.compose.material3.MaterialTheme
2931
import androidx.compose.material3.OutlinedTextField
3032
import androidx.compose.material3.Scaffold
33+
import androidx.compose.material3.SnackbarHost
34+
import androidx.compose.material3.SnackbarHostState
3135
import androidx.compose.material3.Text
3236
import androidx.compose.material3.TopAppBar
3337
import androidx.compose.runtime.Composable
3438
import androidx.compose.runtime.LaunchedEffect
3539
import androidx.compose.runtime.getValue
3640
import androidx.compose.runtime.mutableStateOf
3741
import androidx.compose.runtime.remember
38-
import androidx.compose.runtime.rememberCoroutineScope
3942
import androidx.compose.runtime.setValue
40-
import kotlinx.coroutines.launch
43+
import androidx.compose.runtime.snapshotFlow
4144
import androidx.compose.ui.Alignment
4245
import androidx.compose.ui.Modifier
46+
import androidx.compose.ui.platform.LocalResources
4347
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
4452
import androidx.compose.ui.text.font.FontWeight
4553
import androidx.compose.ui.text.input.KeyboardCapitalization
54+
import androidx.compose.ui.text.style.TextAlign
4655
import androidx.compose.ui.tooling.preview.Preview
4756
import androidx.compose.ui.unit.dp
48-
import androidx.compose.foundation.text.KeyboardOptions
49-
import android.content.res.Configuration.UI_MODE_NIGHT_YES
50-
import androidx.compose.material3.SnackbarHost
51-
import androidx.compose.material3.SnackbarHostState
52-
import androidx.compose.ui.platform.LocalResources
53-
import androidx.compose.ui.text.style.TextAlign
5457
import org.wordpress.android.R
55-
import org.wordpress.android.support.aibot.util.formatRelativeTime
56-
import org.wordpress.android.support.aibot.util.generateSampleBotConversations
5758
import org.wordpress.android.support.aibot.model.BotConversation
5859
import org.wordpress.android.support.aibot.model.BotMessage
60+
import org.wordpress.android.support.aibot.util.formatRelativeTime
61+
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,22 +322,26 @@ 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(
277-
text = message.text,
278-
style = MaterialTheme.typography.bodyMedium,
279-
color = if (message.isWrittenByUser) {
280-
MaterialTheme.colorScheme.onPrimaryContainer
281-
} else {
282-
MaterialTheme.colorScheme.onSurfaceVariant
283-
}
331+
text = message.formattedText,
332+
style = MaterialTheme.typography.bodyMedium.copy(
333+
color = if (message.isWrittenByUser) {
334+
MaterialTheme.colorScheme.onPrimaryContainer
335+
} else {
336+
MaterialTheme.colorScheme.onSurfaceVariant
337+
}
338+
)
284339
)
285340

286341
Spacer(modifier = Modifier.height(4.dp))
287342

288343
Text(
289-
text = formatRelativeTime(message.date, resources),
344+
text = timestamp,
290345
style = MaterialTheme.typography.bodySmall,
291346
color = if (message.isWrittenByUser) {
292347
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
@@ -317,6 +372,7 @@ private fun TypingIndicatorBubble() {
317372
)
318373
)
319374
.padding(16.dp)
375+
.semantics { contentDescription = "AI Bot is typing" }
320376
) {
321377
Row(
322378
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -368,9 +424,12 @@ private fun ConversationDetailScreenPreview() {
368424
conversation = sampleConversation,
369425
isLoading = false,
370426
isBotTyping = false,
427+
isLoadingOlderMessages = false,
428+
hasMorePages = false,
371429
canSendMessage = true,
372430
onBackClick = { },
373-
onSendMessage = { }
431+
onSendMessage = { },
432+
onLoadOlderMessages = { }
374433
)
375434
}
376435
}
@@ -388,9 +447,12 @@ private fun ConversationDetailScreenPreviewDark() {
388447
conversation = sampleConversation,
389448
isLoading = false,
390449
isBotTyping = false,
450+
isLoadingOlderMessages = false,
451+
hasMorePages = false,
391452
canSendMessage = true,
392453
onBackClick = { },
393-
onSendMessage = { }
454+
onSendMessage = { },
455+
onLoadOlderMessages = { }
394456
)
395457
}
396458
}
@@ -408,9 +470,12 @@ private fun ConversationDetailScreenWordPressPreview() {
408470
conversation = sampleConversation,
409471
isLoading = false,
410472
isBotTyping = false,
473+
isLoadingOlderMessages = false,
474+
hasMorePages = false,
411475
canSendMessage = true,
412476
onBackClick = { },
413-
onSendMessage = { }
477+
onSendMessage = { },
478+
onLoadOlderMessages = { }
414479
)
415480
}
416481
}
@@ -428,9 +493,12 @@ private fun ConversationDetailScreenPreviewWordPressDark() {
428493
conversation = sampleConversation,
429494
isLoading = false,
430495
isBotTyping = false,
496+
isLoadingOlderMessages = false,
497+
hasMorePages = false,
431498
canSendMessage = true,
432499
onBackClick = { },
433-
onSendMessage = { }
500+
onSendMessage = { },
501+
onLoadOlderMessages = { }
434502
)
435503
}
436504
}

0 commit comments

Comments
 (0)