diff --git a/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt b/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt new file mode 100644 index 000000000000..a6cdc63bd162 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/networking/restapi/WpComApiClientProvider.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.networking.restapi + +import okhttp3.OkHttpClient +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpHttpClient +import rs.wordpress.api.kotlin.WpRequestExecutor +import uniffi.wp_api.WpAuthentication +import uniffi.wp_api.WpAuthenticationProvider +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +private const val READ_WRITE_TIMEOUT = 60L +private const val CONNECT_TIMEOUT = 30L + +class WpComApiClientProvider @Inject constructor() { + fun getWpComApiClient(accessToken: String): WpComApiClient { + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(READ_WRITE_TIMEOUT, TimeUnit.SECONDS) + .build() + + return WpComApiClient( + requestExecutor = WpRequestExecutor(httpClient = WpHttpClient.CustomOkHttpClient(okHttpClient)), + authProvider = WpAuthenticationProvider.staticWithAuth(WpAuthentication.Bearer(token = accessToken!!) + ) + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt index 032944ba0743..32563ff5d443 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/model/BotMessage.kt @@ -6,6 +6,5 @@ data class BotMessage( val id: Long, val text: String, val date: Date, - val userWantsToTalkToHuman: Boolean, val isWrittenByUser: Boolean ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt new file mode 100644 index 000000000000..b4ff0cedc7e0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepository.kt @@ -0,0 +1,160 @@ +package org.wordpress.android.support.aibot.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.IO_THREAD +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import org.wordpress.android.support.aibot.model.BotConversation +import org.wordpress.android.support.aibot.model.BotMessage +import org.wordpress.android.util.AppLog +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AddMessageToBotConversationParams +import uniffi.wp_api.BotConversationSummary +import uniffi.wp_api.CreateBotConversationParams +import uniffi.wp_api.GetBotConversationParams +import javax.inject.Inject +import javax.inject.Named + +private const val BOT_ID = "jetpack-chat-mobile" + +class AIBotSupportRepository @Inject constructor( + private val appLogWrapper: AppLogWrapper, + private val wpComApiClientProvider: WpComApiClientProvider, + @Named(IO_THREAD) private val ioDispatcher: CoroutineDispatcher, +) { + private var accessToken: String? = null + private var userId: Long = 0 + + private val wpComApiClient: WpComApiClient by lazy { + check(accessToken != null || userId != 0L) { "Repository not initialized" } + wpComApiClientProvider.getWpComApiClient(accessToken!!) + } + + fun init(accessToken: String, userId: Long) { + this.accessToken = accessToken + this.userId = userId + } + + suspend fun loadConversations(): List = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportBots().getBotConverationList(BOT_ID) + } + when (response) { + is WpRequestResult.Success -> { + val conversations = response.response.data + conversations.toBotConversations() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversations: $response") + emptyList() + } + } + } + + suspend fun loadConversation(chatId: Long): BotConversation? = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportBots().getBotConversation( + botId = BOT_ID, + chatId = chatId.toULong(), + params = GetBotConversationParams() + ) + } + when (response) { + is WpRequestResult.Success -> { + val conversation = response.response.data + conversation.toBotConversation() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation $chatId: $response") + null + } + } + } + + suspend fun createNewConversation(message: String): BotConversation? = withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportBots().createBotConversation( + botId = BOT_ID, + CreateBotConversationParams( + message = message, + userId = userId + ) + ) + } + + when (response) { + is WpRequestResult.Success -> { + val conversation = response.response.data + conversation.toBotConversation() + } + + else -> { + appLogWrapper.e(AppLog.T.SUPPORT, "Error creating new conversation $response") + null + } + } + } + + suspend fun sendMessageToConversation(chatId: Long, message: String): BotConversation? = + withContext(ioDispatcher) { + val response = wpComApiClient.request { requestBuilder -> + requestBuilder.supportBots().addMessageToBotConversation( + botId = BOT_ID, + chatId = chatId.toULong(), + params = AddMessageToBotConversationParams( + message = message, + context = mapOf() + ) + ) + } + + when (response) { + is WpRequestResult.Success -> { + val conversation = response.response.data + conversation.toBotConversation() + } + + else -> { + appLogWrapper.e( + AppLog.T.SUPPORT, + "Error sending message to conversation $chatId: $response" + ) + null + } + } + } + + private fun List.toBotConversations(): List = + map { it.toBotConversation() } + + + private fun BotConversationSummary.toBotConversation(): BotConversation = + BotConversation ( + id = chatId.toLong(), + createdAt = createdAt, + mostRecentMessageDate = lastMessage.createdAt, + lastMessage = lastMessage.content, + messages = listOf() + ) + + private fun uniffi.wp_api.BotConversation.toBotConversation(): BotConversation = + BotConversation ( + id = chatId.toLong(), + createdAt = createdAt, + mostRecentMessageDate = messages.last().createdAt, + lastMessage = messages.last().content, + messages = messages.map { it.toBotMessage() } + ) + + private fun uniffi.wp_api.BotMessage.toBotMessage(): BotMessage = + BotMessage( + id = messageId.toLong(), + text = content, + date = createdAt, + isWrittenByUser = role == "user" + ) +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt index aaf7d787d831..3c3700b73ac8 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle +import android.view.Gravity import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable @@ -11,12 +12,16 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.wordpress.android.R import org.wordpress.android.ui.compose.theme.AppThemeM3 +import org.wordpress.android.util.ToastUtils @AndroidEntryPoint class AIBotSupportActivity : AppCompatActivity() { @@ -42,7 +47,24 @@ class AIBotSupportActivity : AppCompatActivity() { } } ) - viewModel.init(intent.getStringExtra(ACCESS_TOKEN_ID)!!) + viewModel.init( + accessToken = intent.getStringExtra(ACCESS_TOKEN_ID)!!, + userId = intent.getLongExtra(USER_ID, 0) + ) + + // Observe error messages and show them as Toast + lifecycleScope.launch { + viewModel.errorMessage.collect { errorType -> + val errorMessage = when (errorType) { + AIBotSupportViewModel.ErrorType.GENERAL -> getString(R.string.ai_bot_generic_error) + null -> null + } + errorMessage?.let { + ToastUtils.showToast(this@AIBotSupportActivity, it, ToastUtils.Duration.LONG, Gravity.CENTER) + viewModel.clearError() + } + } + } } private enum class ConversationScreen { @@ -60,28 +82,39 @@ class AIBotSupportActivity : AppCompatActivity() { startDestination = ConversationScreen.List.name ) { composable(route = ConversationScreen.List.name) { + val isLoadingConversations by viewModel.isLoadingConversations.collectAsState() ConversationsListScreen( conversations = viewModel.conversations, + isLoading = isLoadingConversations, onConversationClick = { conversation -> - viewModel.selectConversation(conversation) + viewModel.onConversationSelected(conversation) navController.navigate(ConversationScreen.Detail.name) }, onBackClick = { finish() }, onCreateNewConversationClick = { - viewModel.createNewConversation() + viewModel.onNewConversationClicked() viewModel.selectedConversation.value?.let { newConversation -> navController.navigate(ConversationScreen.Detail.name) } + }, + onRefresh = { + viewModel.refreshConversations() } ) } composable(route = ConversationScreen.Detail.name) { val selectedConversation by viewModel.selectedConversation.collectAsState() + val isLoadingConversation by viewModel.isLoadingConversation.collectAsState() + val isBotTyping by viewModel.isBotTyping.collectAsState() + val canSendMessage by viewModel.canSendMessage.collectAsState() selectedConversation?.let { conversation -> ConversationDetailScreen( userName = userName, conversation = conversation, + isLoading = isLoadingConversation, + isBotTyping = isBotTyping, + canSendMessage = canSendMessage, onBackClick = { navController.navigateUp() }, onSendMessage = { text -> viewModel.sendMessage(text) @@ -95,14 +128,17 @@ class AIBotSupportActivity : AppCompatActivity() { companion object { private const val ACCESS_TOKEN_ID = "arg_access_token_id" + private const val USER_ID = "arg_user_id" private const val USERNAME = "arg_username" @JvmStatic fun createIntent( context: Context, accessToken: String, + userId: Long, userName: String, ): Intent = Intent(context, AIBotSupportActivity::class.java).apply { putExtra(ACCESS_TOKEN_ID, accessToken) + putExtra(USER_ID, userId) putExtra(USERNAME, userName) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt index 3245f63cba35..a5a7388d9537 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModel.kt @@ -7,151 +7,188 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.wordpress.android.support.aibot.util.generateSampleBotConversations +import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage -import rs.wordpress.api.kotlin.WpComApiClient -import rs.wordpress.api.kotlin.WpRequestResult -import uniffi.wp_api.BotConversationSummary -import uniffi.wp_api.WpAuthentication -import uniffi.wp_api.WpAuthenticationProvider +import org.wordpress.android.support.aibot.repository.AIBotSupportRepository +import org.wordpress.android.util.AppLog import java.util.Date import javax.inject.Inject - -private const val BOT_ID = "jetpack-chat-mobile" +import kotlin.Long @HiltViewModel -class AIBotSupportViewModel @Inject constructor() : ViewModel() { +class AIBotSupportViewModel @Inject constructor( + private val aiBotSupportRepository: AIBotSupportRepository, + private val appLogWrapper: AppLogWrapper, +) : ViewModel() { private val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() private val _selectedConversation = MutableStateFlow(null) val selectedConversation: StateFlow = _selectedConversation.asStateFlow() - private lateinit var accessToken: String + private val _canSendMessage = MutableStateFlow(true) + val canSendMessage: StateFlow = _canSendMessage.asStateFlow() - private val wpComApiClient: WpComApiClient by lazy { - WpComApiClient( - WpAuthenticationProvider.staticWithAuth(WpAuthentication.Bearer(token = accessToken) - ) - ) - } + private val _isLoadingConversation = MutableStateFlow(false) + val isLoadingConversation: StateFlow = _isLoadingConversation.asStateFlow() - fun init(accessToken: String) { - loadDummyData() + private val _isLoadingConversations = MutableStateFlow(false) + val isLoadingConversations: StateFlow = _isLoadingConversations.asStateFlow() - this.accessToken = accessToken -// loadConversations() - } + private val _isBotTyping = MutableStateFlow(false) + val isBotTyping: StateFlow = _isBotTyping.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() - fun loadConversations() { + @Suppress("TooGenericExceptionCaught") + fun init(accessToken: String, userId: Long) { viewModelScope.launch { - val response = wpComApiClient.request { requestBuilder -> - requestBuilder.supportBots().getBotConverationList(BOT_ID) + try { + aiBotSupportRepository.init(accessToken, userId) + loadConversations() + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error initialising the AI bot support repository: " + + "${throwable.message} - ${throwable.stackTraceToString()}") } - when (response) { - is WpRequestResult.Success -> { - val conversations = response.response.data - _conversations.value = conversations.toBotConversations() - } + } + } - else -> { - // stub for now - } - } + @Suppress("TooGenericExceptionCaught") + private suspend fun loadConversations() { + try { + _isLoadingConversations.value = true + val conversations = aiBotSupportRepository.loadConversations() + _conversations.value = conversations + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversations: " + + "${throwable.message} - ${throwable.stackTraceToString()}") } + _isLoadingConversations.value = false } - fun selectConversation(conversation: BotConversation) { - _selectedConversation.value = conversation + fun refreshConversations() { + viewModelScope.launch { + loadConversations() + } } - fun createNewConversation() { - val now = Date() + fun clearError() { + _errorMessage.value = null + } - // Create initial bot greeting message - val greetingMessage = BotMessage( - id = 0, - text = "Hi! I'm here to help you with any questions about WordPress. How can I assist you today?", - date = now, - userWantsToTalkToHuman = false, - isWrittenByUser = false - ) + @Suppress("TooGenericExceptionCaught") + fun onConversationSelected(conversation: BotConversation) { + viewModelScope.launch { + try { + _isLoadingConversation.value = true + _selectedConversation.value = conversation + _canSendMessage.value = true + val updatedConversation = aiBotSupportRepository.loadConversation(conversation.id) + if (updatedConversation != null) { + _selectedConversation.value = updatedConversation + } else { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + + "error retrieving it from server") + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error loading conversation: " + + "${throwable.message} - ${throwable.stackTraceToString()}") + } + _isLoadingConversation.value = false + } + } - val newConversation = BotConversation( + fun onNewConversationClicked() { + val now = Date() + _selectedConversation.value = BotConversation( id = 0, - mostRecentMessageDate = now, - messages = listOf(greetingMessage), createdAt = now, - lastMessage = greetingMessage.text + mostRecentMessageDate = now, + lastMessage = "", + messages = listOf() ) - - // Add to the top of the conversations list - _conversations.value = listOf(newConversation) + _conversations.value - - // Select the new conversation - _selectedConversation.value = newConversation + _canSendMessage.value = true } - fun sendMessage(text: String) { - val currentConversation = _selectedConversation.value ?: return - val now = Date() - val userMessageId = System.currentTimeMillis() - - // Create new user message - val userMessage = BotMessage( - id = userMessageId, - text = text, - date = now, - userWantsToTalkToHuman = false, - isWrittenByUser = true - ) - - // Create bot response (dummy response for now) - val botMessage = BotMessage( - id = userMessageId + 1, // Ensure unique ID by incrementing - text = "Thanks for your message! This is a dummy response. In a real implementation, " + - "this would connect to the support bot API.", - date = Date(now.time + 1), // Slightly later timestamp - userWantsToTalkToHuman = false, - isWrittenByUser = false - ) - - // Update conversation with new messages - val updatedMessages = currentConversation.messages + listOf(userMessage, botMessage) - val updatedConversation = currentConversation.copy( - messages = updatedMessages, - mostRecentMessageDate = botMessage.date, - lastMessage = botMessage.text, - ) - - // Update the conversation in the list - _conversations.value = _conversations.value.map { conversation -> - if (conversation.id == updatedConversation.id) { - updatedConversation - } else { - conversation + @Suppress("TooGenericExceptionCaught") + fun sendMessage(message: String) { + viewModelScope.launch { + try { + // Show bot typing indicator and limit send messages + _isBotTyping.value = true + _canSendMessage.value = false + + val now = Date() + val userMessage = BotMessage( + id = System.currentTimeMillis(), + text = message, + date = now, + isWrittenByUser = true + ) + val currentMessages = (_selectedConversation.value?.messages ?: emptyList()) + userMessage + _selectedConversation.value = _selectedConversation.value?.copy( + messages = currentMessages + ) + + val conversation = sendMessageToBot(message) + + // Hide bot typing indicator + _isBotTyping.value = false + + if (conversation != null) { + val finalConversation = conversation.copy( + lastMessage = conversation.messages.last().text, + messages = (_selectedConversation.value?.messages ?: emptyList()) + conversation.messages + ) + // Update the conversations list + val currentConversations =_conversations.value + if (currentConversations.none { it.id == conversation.id }) { + // It's a new conversation, so add it to the top + _conversations.value = listOf(conversation) + _conversations.value + } else { + // The conversation exists, so we modify it + _conversations.value = _conversations.value.map { + if (it.id == conversation.id) { + finalConversation + } else { + it + } + } + } + + // Update the selected conversation + _selectedConversation.value = finalConversation + } else { + _errorMessage.value = ErrorType.GENERAL + appLogWrapper.e(AppLog.T.SUPPORT, "Error sending message: response is null") + } + } catch (throwable: Throwable) { + _errorMessage.value = ErrorType.GENERAL + _isBotTyping.value = false + appLogWrapper.e(AppLog.T.SUPPORT, "Error sending message: " + + "${throwable.message} - ${throwable.stackTraceToString()}") } - } - // Update selected conversation - _selectedConversation.value = updatedConversation + // Be sure we allow the user to send messages again + _canSendMessage.value = true + } } - private fun loadDummyData() { - _conversations.value = generateSampleBotConversations() + private suspend fun sendMessageToBot(message: String): BotConversation? { + val conversationId = _selectedConversation.value?.id ?: 0L + return if (conversationId == 0L) { + // This is a new conversation, so we need to create it first + aiBotSupportRepository.createNewConversation(message) + } else { + aiBotSupportRepository.sendMessageToConversation(conversationId, message) + } } - private fun List.toBotConversations(): List = - map { it.toBotConversation() } - - - private fun BotConversationSummary.toBotConversation(): BotConversation = - BotConversation ( - id = chatId.toLong(), - createdAt = createdAt, - mostRecentMessageDate = lastMessage.createdAt, - lastMessage = lastMessage.content, - messages = listOf() - ) + enum class ErrorType { GENERAL } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt index 675cd081652f..69f090e3af02 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationDetailScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -20,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -43,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.text.style.TextAlign import org.wordpress.android.R import org.wordpress.android.support.aibot.util.formatRelativeTime @@ -55,6 +58,9 @@ import org.wordpress.android.ui.compose.theme.AppThemeM3 @Composable fun ConversationDetailScreen( conversation: BotConversation, + isLoading: Boolean, + isBotTyping: Boolean, + canSendMessage: Boolean, userName: String, onBackClick: () -> Unit, onSendMessage: (String) -> Unit @@ -63,15 +69,19 @@ fun ConversationDetailScreen( val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() - // Scroll to bottom when conversation changes or messages are added - LaunchedEffect(conversation.id, conversation.messages.size) { - if (conversation.messages.isNotEmpty()) { + // Scroll to bottom when conversation changes or messages are added or typing state changes + LaunchedEffect(conversation.id, conversation.messages.size, isBotTyping) { + if (conversation.messages.isNotEmpty() || isBotTyping) { coroutineScope.launch { - listState.animateScrollToItem(conversation.messages.size + 1) // +1 for spacer + // +2 for welcome header and spacer, +1 if typing indicator is showing + val itemCount = conversation.messages.size + 2 + if (isBotTyping) 1 else 0 + listState.animateScrollToItem(itemCount) } } } + val resources = LocalResources.current + Scaffold( topBar = { TopAppBar( @@ -89,6 +99,7 @@ fun ConversationDetailScreen( bottomBar = { ChatInputBar( messageText = messageText, + canSendMessage = canSendMessage, onMessageTextChange = { messageText = it }, onSendClick = { if (messageText.isNotBlank()) { @@ -99,28 +110,47 @@ fun ConversationDetailScreen( ) } ) { contentPadding -> - LazyColumn( + Box( modifier = Modifier .fillMaxSize() .padding(contentPadding) - .padding(horizontal = 16.dp), - state = listState, - verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - WelcomeHeader(userName) - } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(horizontal = 16.dp), + state = listState, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + WelcomeHeader(userName) + } + + // Key ensures the items recompose when messages change + items( + items = conversation.messages, + key = { message -> message.id } + ) { message -> + MessageBubble(message = message, resources = resources) + } + + // Show typing indicator when bot is typing + if (isBotTyping) { + item { + TypingIndicatorBubble() + } + } - // Key ensures the items recompose when messages change - items( - items = conversation.messages, - key = { message -> message.id } - ) { message -> - MessageBubble(message = message) + item { + Spacer(modifier = Modifier.height(8.dp)) + } } - item { - Spacer(modifier = Modifier.height(8.dp)) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) } } } @@ -169,9 +199,12 @@ private fun WelcomeHeader(userName: String) { @Composable private fun ChatInputBar( messageText: String, + canSendMessage: Boolean, onMessageTextChange: (String) -> Unit, onSendClick: () -> Unit ) { + val canSend = messageText.isNotBlank() && canSendMessage + Row( modifier = Modifier .fillMaxWidth() @@ -184,17 +217,17 @@ private fun ChatInputBar( onValueChange = onMessageTextChange, modifier = Modifier.weight(1f), placeholder = { Text(stringResource(R.string.ai_bot_message_input_placeholder)) }, - maxLines = 4 + maxLines = 4, ) IconButton( onClick = onSendClick, - enabled = messageText.isNotBlank() + enabled = canSend ) { Icon( imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.ai_bot_send_button_content_description), - tint = if (messageText.isNotBlank()) { + tint = if (canSend) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) @@ -205,7 +238,7 @@ private fun ChatInputBar( } @Composable -private fun MessageBubble(message: BotMessage) { +private fun MessageBubble(message: BotMessage, resources: android.content.res.Resources) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (message.isWrittenByUser) { @@ -246,7 +279,7 @@ private fun MessageBubble(message: BotMessage) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = formatRelativeTime(message.date), + text = formatRelativeTime(message.date, resources), style = MaterialTheme.typography.bodySmall, color = if (message.isWrittenByUser) { MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) @@ -259,6 +292,62 @@ private fun MessageBubble(message: BotMessage) { } } +@Composable +private fun TypingIndicatorBubble() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + bottomStart = 4.dp, + bottomEnd = 16.dp + ) + ) + .padding(16.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TypingDot(delay = 0) + TypingDot(delay = 150) + TypingDot(delay = 300) + } + } + } +} + +@Composable +private fun TypingDot(delay: Int) { + var alpha by remember { mutableStateOf(0.3f) } + + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(delay.toLong()) + while (true) { + alpha = 1f + kotlinx.coroutines.delay(600) + alpha = 0.3f + kotlinx.coroutines.delay(600) + } + } + + Box( + modifier = Modifier + .padding(2.dp) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + shape = RoundedCornerShape(50) + ) + .padding(4.dp) + ) +} + @Preview(showBackground = true, name = "Conversation Detail") @Composable private fun ConversationDetailScreenPreview() { @@ -268,6 +357,9 @@ private fun ConversationDetailScreenPreview() { ConversationDetailScreen( userName = "UserName", conversation = sampleConversation, + isLoading = false, + isBotTyping = false, + canSendMessage = true, onBackClick = { }, onSendMessage = { } ) @@ -283,6 +375,9 @@ private fun ConversationDetailScreenPreviewDark() { ConversationDetailScreen( userName = "UserName", conversation = sampleConversation, + isLoading = false, + isBotTyping = false, + canSendMessage = true, onBackClick = { }, onSendMessage = { } ) @@ -298,6 +393,9 @@ private fun ConversationDetailScreenWordPressPreview() { ConversationDetailScreen( userName = "UserName", conversation = sampleConversation, + isLoading = false, + isBotTyping = false, + canSendMessage = true, onBackClick = { }, onSendMessage = { } ) @@ -313,6 +411,9 @@ private fun ConversationDetailScreenPreviewWordPressDark() { ConversationDetailScreen( userName = "UserName", conversation = sampleConversation, + isLoading = false, + isBotTyping = false, + canSendMessage = true, onBackClick = { }, onSendMessage = { } ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt index fc5ec27dfd64..8773f0c2b6f6 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/ui/ConversationsListScreen.kt @@ -1,5 +1,7 @@ package org.wordpress.android.support.aibot.ui +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Resources import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -7,11 +9,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -22,34 +26,37 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import kotlinx.coroutines.flow.MutableStateFlow -import org.wordpress.android.R import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import org.wordpress.android.R +import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.util.formatRelativeTime import org.wordpress.android.support.aibot.util.generateSampleBotConversations -import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.ui.compose.theme.AppThemeM3 -import kotlin.collections.List @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationsListScreen( conversations: StateFlow>, + isLoading: Boolean, onConversationClick: (BotConversation) -> Unit, onBackClick: () -> Unit, onCreateNewConversationClick: () -> Unit, + onRefresh: () -> Unit, ) { Scaffold( topBar = { @@ -74,11 +81,74 @@ fun ConversationsListScreen( ) }, ) { contentPadding -> - ShowConversationsList( - modifier = Modifier.padding(contentPadding), - conversations = conversations, - onConversationClick = onConversationClick + val conversationsList by conversations.collectAsState() + + PullToRefreshBox( + isRefreshing = isLoading, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + when { + conversationsList.isEmpty() && !isLoading -> { + EmptyConversationsView( + modifier = Modifier.fillMaxSize(), + onCreateNewConversationClick = onCreateNewConversationClick + ) + } + else -> { + ShowConversationsList( + modifier = Modifier.fillMaxSize(), + conversations = conversations, + onConversationClick = onConversationClick + ) + } + } + } + } +} + +@Composable +private fun EmptyConversationsView( + modifier: Modifier, + onCreateNewConversationClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "💬", + style = MaterialTheme.typography.displayLarge + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(R.string.ai_bot_empty_conversations_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface ) + + Spacer(modifier = Modifier.padding(8.dp)) + + Text( + text = stringResource(R.string.ai_bot_empty_conversations_message), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.padding(24.dp)) + + Button(onClick = onCreateNewConversationClick) { + Text(text = stringResource(R.string.ai_bot_empty_conversations_button)) + } } } @@ -89,6 +159,7 @@ private fun ShowConversationsList( onConversationClick: (BotConversation) -> Unit ) { val conversations by conversations.collectAsState() + val resources = LocalResources.current LazyColumn( modifier = modifier @@ -104,6 +175,7 @@ private fun ShowConversationsList( items(conversations) { conversation -> ConversationCard( conversation = conversation, + resources = resources, onClick = { onConversationClick(conversation) } ) } @@ -118,6 +190,7 @@ private fun ShowConversationsList( @Composable private fun ConversationCard( conversation: BotConversation, + resources: Resources, onClick: () -> Unit ) { Card( @@ -147,7 +220,7 @@ private fun ConversationCard( Text( modifier = Modifier.padding(top = 8.dp), - text = formatRelativeTime(conversation.mostRecentMessageDate), + text = formatRelativeTime(conversation.mostRecentMessageDate, resources), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -161,12 +234,14 @@ private fun ConversationCard( private fun ConversationsScreenPreview() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) - MaterialTheme(colorScheme = lightColorScheme()) { + AppThemeM3(isDarkTheme = false) { ConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, + onRefresh = { }, ) } } @@ -176,12 +251,14 @@ private fun ConversationsScreenPreview() { private fun ConversationsScreenPreviewDark() { val sampleConversations = MutableStateFlow(generateSampleBotConversations()) - MaterialTheme(colorScheme = darkColorScheme()) { + AppThemeM3(isDarkTheme = true) { ConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, + onRefresh = { }, ) } } @@ -194,9 +271,11 @@ private fun ConversationsScreenWordPressPreview() { AppThemeM3(isDarkTheme = false, isJetpackApp = false) { ConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoading = true, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, + onRefresh = { }, ) } } @@ -209,9 +288,45 @@ private fun ConversationsScreenPreviewWordPressDark() { AppThemeM3(isDarkTheme = true, isJetpackApp = false) { ConversationsListScreen( conversations = sampleConversations.asStateFlow(), + isLoading = true, + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { }, + onRefresh = { }, + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations List") +@Composable +private fun EmptyConversationsScreenPreview() { + val emptyConversations = MutableStateFlow(emptyList()) + + AppThemeM3(isDarkTheme = false) { + ConversationsListScreen( + conversations = emptyConversations.asStateFlow(), + isLoading = false, + onConversationClick = { }, + onBackClick = { }, + onCreateNewConversationClick = { }, + onRefresh = { }, + ) + } +} + +@Preview(showBackground = true, name = "Empty Conversations List - Dark", uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun EmptyConversationsScreenPreviewDark() { + val emptyConversations = MutableStateFlow(emptyList()) + + AppThemeM3(isDarkTheme = true) { + ConversationsListScreen( + conversations = emptyConversations.asStateFlow(), + isLoading = false, onConversationClick = { }, onBackClick = { }, onCreateNewConversationClick = { }, + onRefresh = { }, ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt b/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt index e33a42110fa6..7c595b383e96 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/aibot/util/ConversationUtils.kt @@ -1,7 +1,6 @@ package org.wordpress.android.support.aibot.util -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import android.content.res.Resources import org.wordpress.android.R import org.wordpress.android.support.aibot.model.BotConversation import org.wordpress.android.support.aibot.model.BotMessage @@ -11,9 +10,8 @@ import java.util.Locale import java.util.concurrent.TimeUnit -@Composable -fun formatRelativeTime(date: Date): String { - val context = LocalContext.current +@Suppress("MagicNumber") +fun formatRelativeTime(date: Date, res: Resources): String { val now = Date() val diffMillis = now.time - date.time val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis) @@ -21,29 +19,29 @@ fun formatRelativeTime(date: Date): String { val diffDays = TimeUnit.MILLISECONDS.toDays(diffMillis) return when { - diffMinutes < 1 -> context.getString(R.string.ai_bot_time_just_now) - diffMinutes < 60 -> if (diffMinutes == 1L) { - context.getString(R.string.ai_bot_time_minute_ago, diffMinutes) - } else { - context.getString(R.string.ai_bot_time_minutes_ago, diffMinutes) - } - diffHours < 24 -> if (diffHours == 1L) { - context.getString(R.string.ai_bot_time_hour_ago, diffHours) - } else { - context.getString(R.string.ai_bot_time_hours_ago, diffHours) - } - diffDays < 7 -> if (diffDays == 1L) { - context.getString(R.string.ai_bot_time_day_ago, diffDays) - } else { - context.getString(R.string.ai_bot_time_days_ago, diffDays) - } + diffMinutes < 1 -> res.getString(R.string.ai_bot_time_just_now) + diffMinutes < 60 -> res.getQuantityString( + R.plurals.ai_bot_time_minutes_ago, + diffMinutes.toInt(), + diffMinutes + ) + diffHours < 24 -> res.getQuantityString( + R.plurals.ai_bot_time_hours_ago, + diffHours.toInt(), + diffHours + ) + diffDays < 7 -> res.getQuantityString( + R.plurals.ai_bot_time_days_ago, + diffDays.toInt(), + diffDays + ) diffDays < 30 -> { val weeks = diffDays / 7 - if (weeks == 1L) { - context.getString(R.string.ai_bot_time_week_ago, weeks) - } else { - context.getString(R.string.ai_bot_time_weeks_ago, weeks) - } + res.getQuantityString( + R.plurals.ai_bot_time_weeks_ago, + weeks.toInt(), + weeks + ) } else -> { val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) @@ -68,7 +66,6 @@ fun generateSampleBotConversations(): List { text = "Hi, I'm having trouble with the app. It keeps crashing when I try to open it after " + "the latest update. Can you help?", date = Date(now.time - 3_600_000), // 1 hour ago - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -77,14 +74,12 @@ fun generateSampleBotConversations(): List { "this issue. Let me ask a few questions to better understand what's happening. " + "What device are you using and what Android version are you running?", date = Date(now.time - 3_540_000), // 59 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = false ), BotMessage( id = 1003, text = "I'm using a Pixel 8 Pro with Android 14. The app worked fine before the update yesterday.", date = Date(now.time - 3_480_000), // 58 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -95,7 +90,6 @@ fun generateSampleBotConversations(): List { "3. As a last resort, you might need to clear app data or reinstall\n\nCan you try " + "step 1 first and let me know if that helps?", date = Date(now.time - 3_420_000), // 57 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = false ), BotMessage( @@ -103,7 +97,6 @@ fun generateSampleBotConversations(): List { text = "I tried force-closing and restarting my phone, but it's still crashing immediately when " + "I tap the app icon. Should I try reinstalling?", date = Date(now.time - 3_300_000), // 55 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -114,14 +107,12 @@ fun generateSampleBotConversations(): List { "3. Sign back into your account\n\nYour data should be preserved if you're signed " + "into your account. Give this a try and let me know how it goes!", date = Date(now.time - 3_240_000), // 54 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = false ), BotMessage( id = 1007, text = "That worked! The app is opening normally now. Thank you so much for your help!", date = Date(now.time - 180_000), // 3 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -130,7 +121,6 @@ fun generateSampleBotConversations(): List { "fixes problems that occur during app updates. If you run into any other issues, please " + "don't hesitate to reach out. Is there anything else I can help you with today?", date = Date(now.time - 120_000), // 2 minutes ago - userWantsToTalkToHuman = false, isWrittenByUser = false ) ) @@ -147,7 +137,6 @@ fun generateSampleBotConversations(): List { id = 2001, text = "I just created my WordPress site and need help getting started. Where should I begin?", date = Date(now.time - 7_800_000), - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -157,7 +146,6 @@ fun generateSampleBotConversations(): List { "About, Contact)\n3. Set up your site navigation\n4. Add your first blog post\n\n" + "Which of these would you like to tackle first?", date = Date(now.time - 7_200_000), - userWantsToTalkToHuman = false, isWrittenByUser = false ) ) @@ -174,7 +162,6 @@ fun generateSampleBotConversations(): List { id = 3001, text = "How can I change the colors on my site? I want to match my brand.", date = Date(now.time - 87_000_000), - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -183,7 +170,6 @@ fun generateSampleBotConversations(): List { "Most themes allow you to customize colors for backgrounds, text, links, and buttons. " + "Would you like step-by-step instructions?", date = Date(now.time - 86_400_000), - userWantsToTalkToHuman = false, isWrittenByUser = false ) ) @@ -200,7 +186,6 @@ fun generateSampleBotConversations(): List { id = 4001, text = "My site isn't showing up in Google search results. What should I do?", date = Date(now.time - 259_800_000), - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -211,7 +196,6 @@ fun generateSampleBotConversations(): List { "5. Build internal links between pages\n\n" + "Would you like detailed guidance on any of these?", date = Date(now.time - 259_200_000), - userWantsToTalkToHuman = false, isWrittenByUser = false ) ) @@ -228,7 +212,6 @@ fun generateSampleBotConversations(): List { id = 5001, text = "My website seems to be loading slowly. What can I do to speed it up?", date = Date(now.time - 605_400_000), - userWantsToTalkToHuman = false, isWrittenByUser = true ), BotMessage( @@ -238,7 +221,6 @@ fun generateSampleBotConversations(): List { "3. Enable lazy loading for images\n4. Minimize plugins\n" + "5. Use a CDN for static assets\n\nLet me know which area you'd like to focus on first!", date = Date(now.time - 604_800_000), - userWantsToTalkToHuman = false, isWrittenByUser = false ) ) diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt index 7ffd8c942e9a..328ccf07e0d1 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationDetailScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -59,6 +60,7 @@ fun HEConversationDetailScreen( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() var showBottomSheet by remember { mutableStateOf(false) } + val resources = LocalResources.current Scaffold( topBar = { @@ -87,7 +89,7 @@ fun HEConversationDetailScreen( item { ConversationHeader( messageCount = conversation.messages.size, - lastUpdated = formatRelativeTime(conversation.lastMessageSentAt) + lastUpdated = formatRelativeTime(conversation.lastMessageSentAt, resources) ) } @@ -102,7 +104,7 @@ fun HEConversationDetailScreen( MessageItem( authorName = message.authorName, messageText = message.text, - timestamp = formatRelativeTime(message.createdAt), + timestamp = formatRelativeTime(message.createdAt, resources), isUserMessage = message.authorIsUser ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt index 0e26c2c46a55..880f3e268963 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/he/ui/HEConversationsListScreen.kt @@ -1,6 +1,7 @@ package org.wordpress.android.support.he.ui import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Resources import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,6 +28,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -86,6 +88,7 @@ private fun ShowConversationsList( onConversationClick: (SupportConversation) -> Unit ) { val conversationsList by conversations.collectAsState() + val resources = LocalResources.current LazyColumn( modifier = modifier @@ -102,6 +105,7 @@ private fun ShowConversationsList( ) { conversation -> ConversationCard( conversation = conversation, + resources = resources, onClick = { onConversationClick(conversation) } ) Spacer(modifier = Modifier.height(12.dp)) @@ -116,6 +120,7 @@ private fun ShowConversationsList( @Composable private fun ConversationCard( conversation: SupportConversation, + resources: Resources, onClick: () -> Unit ) { Card( @@ -151,7 +156,7 @@ private fun ConversationCard( ) Text( - text = formatRelativeTime(conversation.lastMessageSentAt), + text = formatRelativeTime(conversation.lastMessageSentAt, resources), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp) diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt index 430c96b26105..989e7d0ec595 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt @@ -69,7 +69,7 @@ class SupportActivity : AppCompatActivity() { viewModel.navigationEvents.collect { event -> when (event) { is SupportViewModel.NavigationEvent.NavigateToAskTheBots -> { - navigateToAskTheBots(event.accessToken, event.userName) + navigateToAskTheBots(event.accessToken, event.userId, event.userName) } is SupportViewModel.NavigationEvent.NavigateToLogin -> { navigateToLogin() @@ -84,9 +84,9 @@ class SupportActivity : AppCompatActivity() { } } - private fun navigateToAskTheBots(accessToken: String, userName: String) { + private fun navigateToAskTheBots(accessToken: String, userId: Long, userName: String) { startActivity( - AIBotSupportActivity.Companion.createIntent(this, accessToken, userName) + AIBotSupportActivity.Companion.createIntent(this, accessToken, userId, userName) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 4440d6680747..54029b347e11 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -22,7 +22,11 @@ class SupportViewModel @Inject constructor( private val appLogWrapper: AppLogWrapper, ) : ViewModel() { sealed class NavigationEvent { - data class NavigateToAskTheBots(val accessToken: String, val userName: String) : NavigationEvent() + data class NavigateToAskTheBots( + val accessToken: String, + val userId: Long, + val userName: String + ) : NavigationEvent() data object NavigateToLogin : NavigationEvent() data object NavigateToAskHappinessEngineers : NavigationEvent() } @@ -76,6 +80,7 @@ class SupportViewModel @Inject constructor( _navigationEvents.emit( NavigationEvent.NavigateToAskTheBots( accessToken = accountStore.accessToken!!, // access token has been checked before + userId = account.userId, userName = account.displayName.ifEmpty { account.userName } ) ) diff --git a/WordPress/src/main/res/values/plurals.xml b/WordPress/src/main/res/values/plurals.xml new file mode 100644 index 000000000000..8c69cd6d7730 --- /dev/null +++ b/WordPress/src/main/res/values/plurals.xml @@ -0,0 +1,23 @@ + + + + + %1$d minute ago + %1$d minutes ago + + + + %1$d hour ago + %1$d hours ago + + + + %1$d day ago + %1$d days ago + + + + %1$d week ago + %1$d weeks ago + + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index e10a310ec6e9..7da1edbadeb8 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5123,6 +5123,10 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Conversations Back New conversation + Something went wrong. Please try again later. + No conversations yet + Start a new conversation to get help with your WordPress site or account. + Start conversation Send diff --git a/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt b/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt new file mode 100644 index 000000000000..bacfe7338571 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/support/aibot/repository/AIBotSupportRepositoryTest.kt @@ -0,0 +1,336 @@ +package org.wordpress.android.support.aibot.repository + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.networking.restapi.WpComApiClientProvider +import rs.wordpress.api.kotlin.WpComApiClient +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.BotConversation as ApiBotConversation +import uniffi.wp_api.BotConversationSummary +import uniffi.wp_api.BotMessage as ApiBotMessage +import uniffi.wp_api.BotMessageSummary +import uniffi.wp_api.MessageContext +import uniffi.wp_api.SupportBotsRequestAddMessageToBotConversationResponse +import uniffi.wp_api.SupportBotsRequestCreateBotConversationResponse +import uniffi.wp_api.SupportBotsRequestGetBotConversationResponse +import uniffi.wp_api.SupportBotsRequestGetBotConverationListResponse +import uniffi.wp_api.UserMessageContext +import uniffi.wp_api.UserPaidSupportEligibility +import uniffi.wp_api.WpNetworkHeaderMap +import java.util.Date + +@ExperimentalCoroutinesApi +class AIBotSupportRepositoryTest : BaseUnitTest() { + @Mock + private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var wpComApiClientProvider: WpComApiClientProvider + @Mock + private lateinit var wpComApiClient: WpComApiClient + + private lateinit var repository: AIBotSupportRepository + + private val testAccessToken = "test_access_token" + private val testUserId = 12345L + private val testChatId = 1L + private val testMessage = "Test message" + + @Before + fun setUp() = test { + whenever(wpComApiClientProvider.getWpComApiClient(testAccessToken)).thenReturn(wpComApiClient) + + repository = AIBotSupportRepository( + appLogWrapper = appLogWrapper, + wpComApiClientProvider = wpComApiClientProvider, + testDispatcher() + ) + } + + @Test + fun `loadConversations returns list of conversations on success`() = test { + // Create a mock response object with the data property + val testConversations = listOf( + createTestBotConversationSummary(chatId = 1L, message = "First conversation"), + createTestBotConversationSummary(chatId = 2L, message = "Second conversation") + ) + + // Create the actual response type + val response = SupportBotsRequestGetBotConverationListResponse( + data = testConversations, + headerMap = mock() + ) + + val successResponse = WpRequestResult.Success(response = response) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())) + .thenReturn(successResponse) + + val result = repository.loadConversations() + + assertThat(result).hasSize(2) + assertThat(result[0].id).isEqualTo(1L) + assertThat(result[0].lastMessage).isEqualTo("First conversation") + assertThat(result[1].id).isEqualTo(2L) + assertThat(result[1].lastMessage).isEqualTo("Second conversation") + } + + @Test + fun `loadConversation returns conversation on success`() = test { + val testChatId = 123L + + val userMessage = createUserMessage(1L, "User message") + val botMessage = createBotMessage(2L, "Bot response") + + val testMessages = listOf(userMessage, botMessage) + + val apiConversation = createApiBotConversation( + chatId = testChatId, + messages = testMessages + ) + + val response = SupportBotsRequestGetBotConversationResponse( + data = apiConversation, + headerMap = mock() + ) + + val successResponse = WpRequestResult.Success(response = response) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())) + .thenReturn(successResponse) + + val result = repository.loadConversation(testChatId.toLong()) + + assertThat(result).isNotNull + assertThat(result?.id).isEqualTo(testChatId) + assertThat(result?.messages).hasSize(2) + assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue + assertThat(result?.messages?.get(0)?.text).isEqualTo("User message") + assertThat(result?.messages?.get(1)?.isWrittenByUser).isFalse + assertThat(result?.messages?.get(1)?.text).isEqualTo("Bot response") + assertThat(result?.lastMessage).isEqualTo("Bot response") + } + + @Test + fun `loadConversations returns empty list on error`() = test { + val errorResponse: WpRequestResult = WpRequestResult.UnknownError( + statusCode = 500u.toUShort(), + response = "" + ) + + repository.init(testAccessToken, testUserId) + + // Mock the suspend function call + whenever(wpComApiClient.request(any())).thenReturn(errorResponse) + + val result = repository.loadConversations() + + assertThat(result).isEmpty() + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `loadConversation returns null on error`() = test { + val errorResponse: WpRequestResult = WpRequestResult.UnknownError( + statusCode = 404u.toUShort(), + response = "" + ) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())).thenReturn(errorResponse) + + val result = repository.loadConversation(testChatId) + + assertThat(result).isNull() + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `createNewConversation returns conversation on success`() = test { + val newChatId = 456L + val testMessage = "New conversation message" + + val userMessage = createUserMessage(messageId = 1L, content = testMessage) + val botMessage = createBotMessage(messageId = 2L, content = "Bot welcome response") + + val testMessages = listOf(userMessage, botMessage) + + val apiConversation = createApiBotConversation( + chatId = newChatId, + messages = testMessages + ) + + val response = SupportBotsRequestCreateBotConversationResponse( + data = apiConversation, + headerMap = mock() + ) + + val successResponse = WpRequestResult.Success(response = response) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())) + .thenReturn(successResponse) + + val result = repository.createNewConversation(testMessage) + + assertThat(result).isNotNull + assertThat(result?.id).isEqualTo(newChatId) + assertThat(result?.messages).hasSize(2) + assertThat(result?.messages?.get(0)?.text).isEqualTo(testMessage) + assertThat(result?.messages?.get(0)?.isWrittenByUser).isTrue + assertThat(result?.messages?.get(1)?.text).isEqualTo("Bot welcome response") + assertThat(result?.messages?.get(1)?.isWrittenByUser).isFalse + } + + @Test + fun `createNewConversation returns null on error`() = test { + val errorResponse: WpRequestResult = WpRequestResult.UnknownError( + statusCode = 500u.toUShort(), + response = "" + ) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())).thenReturn(errorResponse) + + val result = repository.createNewConversation(testMessage) + + assertThat(result).isNull() + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `sendMessageToConversation returns updated conversation on success`() = test { + val existingChatId = 789L + val newMessage = "Follow-up message" + + val previousUserMessage = createUserMessage(messageId = 1L, content = "Previous user message") + val previousBotMessage = createBotMessage(messageId = 2L, content = "Previous bot response") + val newUserMessage = createUserMessage(messageId = 3L, content = newMessage) + val newBotMessage = createBotMessage(messageId = 4L, content = "Bot follow-up response") + + val testMessages = listOf(previousUserMessage, previousBotMessage, newUserMessage, newBotMessage) + + val apiConversation = createApiBotConversation( + chatId = existingChatId, + messages = testMessages + ) + + val response = SupportBotsRequestAddMessageToBotConversationResponse( + data = apiConversation, + headerMap = mock() + ) + + val successResponse = WpRequestResult.Success(response = response) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())) + .thenReturn(successResponse) + + val result = repository.sendMessageToConversation(existingChatId.toLong(), newMessage) + + assertThat(result).isNotNull + assertThat(result?.id).isEqualTo(existingChatId) + assertThat(result?.messages).hasSize(4) + assertThat(result?.messages?.get(2)?.text).isEqualTo(newMessage) + assertThat(result?.messages?.get(2)?.isWrittenByUser).isTrue + assertThat(result?.messages?.get(3)?.text).isEqualTo("Bot follow-up response") + assertThat(result?.messages?.get(3)?.isWrittenByUser).isFalse + assertThat(result?.lastMessage).isEqualTo("Bot follow-up response") + } + + @Test + fun `sendMessageToConversation returns null on error`() = test { + val errorResponse: WpRequestResult = WpRequestResult.UnknownError( + statusCode = 500u.toUShort(), + response = "" + ) + + repository.init(testAccessToken, testUserId) + whenever(wpComApiClient.request(any())).thenReturn(errorResponse) + + val result = repository.sendMessageToConversation(testChatId, testMessage) + + assertThat(result).isNull() + verify(appLogWrapper).e(any(), any()) + } + + private fun createTestBotConversationSummary(chatId: Long, message: String): BotConversationSummary { + return BotConversationSummary( + chatId = chatId.toULong(), + createdAt = Date(), + lastMessage = BotMessageSummary( + content = message, + createdAt = Date(), + role = "user" + ) + ) + } + + private fun createUserMessage(messageId: Long, content: String): ApiBotMessage = ApiBotMessage( + messageId = messageId.toULong(), + content = content, + role = "user", + createdAt = Date(), + context = MessageContext.User( + UserMessageContext( + selectedSiteId = null, + wpcomUserId = 1L, + wpcomUserName = "UserName", + userPaidSupportEligibility = UserPaidSupportEligibility( + isUserEligible = true, + wapuuAssistantEnabled = true + ), + plan = null, + products = listOf(), + planInterface = false, + ) + ) + ) + + private fun createBotMessage(messageId: Long, content: String): ApiBotMessage = ApiBotMessage( + messageId = messageId.toULong(), + content = content, + role = "bot", + createdAt = Date(), + context = MessageContext.User( + UserMessageContext( + selectedSiteId = null, + wpcomUserId = 1L, + wpcomUserName = "UserName", + userPaidSupportEligibility = UserPaidSupportEligibility( + isUserEligible = true, + wapuuAssistantEnabled = true + ), + plan = null, + products = listOf(), + planInterface = false, + ) + ) + ) + + private fun createApiBotConversation( + chatId: Long, + messages: List + ): ApiBotConversation = ApiBotConversation( + chatId = chatId.toULong(), + createdAt = Date(), + messages = messages, + wpcomUserId = testUserId, + externalId = "", + externalIdProvider = "", + sessionId = "", + botSlug = "test-bot", + botVersion = "", + zendeskTicketId = "" + ) +} diff --git a/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt new file mode 100644 index 000000000000..745e78867ebb --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/support/aibot/ui/AIBotSupportViewModelTest.kt @@ -0,0 +1,376 @@ +package org.wordpress.android.support.aibot.ui + +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.support.aibot.model.BotConversation +import org.wordpress.android.support.aibot.model.BotMessage +import org.wordpress.android.support.aibot.repository.AIBotSupportRepository +import java.util.Date + +@ExperimentalCoroutinesApi +class AIBotSupportViewModelTest : BaseUnitTest() { + @Mock + private lateinit var aiBotSupportRepository: AIBotSupportRepository + @Mock + private lateinit var appLogWrapper: AppLogWrapper + + private lateinit var viewModel: AIBotSupportViewModel + + private val testAccessToken = "test_access_token" + private val testUserId = 12345L + + @Before + fun setUp() { + viewModel = AIBotSupportViewModel( + aiBotSupportRepository = aiBotSupportRepository, + appLogWrapper = appLogWrapper + ) + } + + @Test + fun `init successfully loads conversations`() = test { + val testConversations = createTestConversations() + whenever(aiBotSupportRepository.loadConversations()).thenReturn(testConversations) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + verify(aiBotSupportRepository).init(testAccessToken, testUserId) + verify(aiBotSupportRepository).loadConversations() + assertThat(viewModel.conversations.value).isEqualTo(testConversations) + assertThat(viewModel.isLoadingConversations.value).isFalse + } + + @Test + fun `init sets error when repository init fails`() = test { + val exception = RuntimeException("Init failed") + whenever(aiBotSupportRepository.init(any(), any())).thenThrow(exception) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `init sets error when loading conversations fails`() = test { + val exception = RuntimeException("Load failed") + whenever(aiBotSupportRepository.loadConversations()).thenThrow(exception) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversations.value).isFalse + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `refreshConversations reloads conversations successfully`() = test { + val initialConversations = createTestConversations() + val updatedConversations = createTestConversations(count = 3) + + whenever(aiBotSupportRepository.loadConversations()) + .thenReturn(initialConversations) + .thenReturn(updatedConversations) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + viewModel.refreshConversations() + advanceUntilIdle() + + assertThat(viewModel.conversations.value).isEqualTo(updatedConversations) + assertThat(viewModel.isLoadingConversations.value).isFalse + } + + @Test + fun `clearError clears the error message`() = test { + whenever(aiBotSupportRepository.loadConversations()).thenThrow(RuntimeException("Error")) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isNotNull + + viewModel.clearError() + + assertThat(viewModel.errorMessage.value).isNull() + } + + @Test + fun `onConversationSelected loads conversation details successfully`() = test { + val conversation = createTestConversation(id = 1L) + val detailedConversation = conversation.copy( + messages = listOf( + BotMessage(1L, "User message", Date(), true), + BotMessage(2L, "Bot response", Date(), false) + ) + ) + whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(detailedConversation) + + viewModel.onConversationSelected(conversation) + advanceUntilIdle() + + assertThat(viewModel.selectedConversation.value).isEqualTo(detailedConversation) + assertThat(viewModel.canSendMessage.value).isTrue + assertThat(viewModel.isLoadingConversation.value).isFalse + } + + @Test + fun `onConversationSelected sets error when repository returns null`() = test { + val conversation = createTestConversation(id = 1L) + whenever(aiBotSupportRepository.loadConversation(1L)).thenReturn(null) + + viewModel.onConversationSelected(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversation.value).isFalse + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `onConversationSelected sets error when repository throws exception`() = test { + val conversation = createTestConversation(id = 1L) + val exception = RuntimeException("Load failed") + whenever(aiBotSupportRepository.loadConversation(1L)).thenThrow(exception) + + viewModel.onConversationSelected(conversation) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isLoadingConversation.value).isFalse + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `onNewConversationClicked creates empty conversation`() = test { + viewModel.onNewConversationClicked() + + val selectedConversation = viewModel.selectedConversation.value + assertThat(selectedConversation).isNotNull + assertThat(selectedConversation?.id).isEqualTo(0L) + assertThat(selectedConversation?.messages).isEmpty() + assertThat(selectedConversation?.lastMessage).isEmpty() + assertThat(viewModel.canSendMessage.value).isTrue + } + + @Test + fun `sendMessage creates new conversation when id is 0`() = test { + val message = "Hello, I need help" + val newConversation = createTestConversation(id = 123L).copy( + messages = listOf( + BotMessage(1L, message, Date(), true), + BotMessage(2L, "Bot response", Date(), false) + ) + ) + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + advanceUntilIdle() + + verify(aiBotSupportRepository).createNewConversation(message) + assertThat(viewModel.conversations.value).contains(newConversation) + assertThat(viewModel.isBotTyping.value).isFalse + assertThat(viewModel.canSendMessage.value).isTrue + } + + @Test + fun `sendMessage sends to existing conversation when id is not 0`() = test { + val conversationId = 456L + val message = "Follow-up question" + val existingConversation = createTestConversation(id = conversationId).copy( + messages = listOf(BotMessage(1L, "Previous message", Date(), true)) + ) + val updatedConversation = existingConversation.copy( + messages = listOf( + BotMessage(1L, "Previous message", Date(), true), + BotMessage(2L, message, Date(), true), + BotMessage(3L, "Bot response", Date(), false) + ) + ) + + whenever(aiBotSupportRepository.loadConversation(conversationId)).thenReturn(existingConversation) + whenever(aiBotSupportRepository.sendMessageToConversation(eq(conversationId), eq(message))) + .thenReturn(updatedConversation) + + viewModel.onConversationSelected(existingConversation) + advanceUntilIdle() + + viewModel.sendMessage(message) + advanceUntilIdle() + + verify(aiBotSupportRepository).sendMessageToConversation(conversationId, message) + assertThat(viewModel.isBotTyping.value).isFalse + assertThat(viewModel.canSendMessage.value).isTrue + } + + @Test + fun `sendMessage shows bot typing indicator during operation`() = test { + val message = "Test message" + val newConversation = createTestConversation(id = 123L) + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + advanceUntilIdle() + + assertThat(viewModel.isBotTyping.value).isFalse + } + + @Test + fun `sendMessage disables message sending during operation`() = test { + val message = "Test message" + val newConversation = createTestConversation(id = 123L) + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + + viewModel.onNewConversationClicked() + assertThat(viewModel.canSendMessage.value).isTrue + + viewModel.sendMessage(message) + advanceUntilIdle() + + assertThat(viewModel.canSendMessage.value).isTrue + } + + @Test + fun `sendMessage adds user message optimistically to selected conversation`() = test { + val message = "Test message" + val newConversation = createTestConversation(id = 123L) + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + + // Allow the optimistic update to complete + advanceUntilIdle() + + val selectedConversation = viewModel.selectedConversation.value + assertThat(selectedConversation?.messages).isNotEmpty + assertThat(selectedConversation?.messages?.first()?.text).isEqualTo(message) + assertThat(selectedConversation?.messages?.first()?.isWrittenByUser).isTrue + } + + @Test + fun `sendMessage sets error when repository returns null`() = test { + val message = "Test message" + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(null) + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isBotTyping.value).isFalse + assertThat(viewModel.canSendMessage.value).isTrue + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `sendMessage sets error and re-enables sending when exception occurs`() = test { + val message = "Test message" + val exception = RuntimeException("Send failed") + whenever(aiBotSupportRepository.createNewConversation(message)).thenThrow(exception) + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + advanceUntilIdle() + + assertThat(viewModel.errorMessage.value).isEqualTo(AIBotSupportViewModel.ErrorType.GENERAL) + assertThat(viewModel.isBotTyping.value).isFalse + assertThat(viewModel.canSendMessage.value).isTrue + verify(appLogWrapper).e(any(), any()) + } + + @Test + fun `sendMessage updates conversations list when creating new conversation`() = test { + val initialConversations = createTestConversations(count = 2) + val message = "New conversation" + val newConversation = createTestConversation(id = 999L).copy( + messages = listOf( + BotMessage(1L, message, Date(), true), + BotMessage(2L, "Bot response", Date(), false) + ) + ) + + whenever(aiBotSupportRepository.loadConversations()).thenReturn(initialConversations) + whenever(aiBotSupportRepository.createNewConversation(message)).thenReturn(newConversation) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + viewModel.onNewConversationClicked() + viewModel.sendMessage(message) + advanceUntilIdle() + + assertThat(viewModel.conversations.value).hasSize(3) + assertThat(viewModel.conversations.value.first().id).isEqualTo(999L) + } + + @Test + fun `sendMessage updates existing conversation in conversations list`() = test { + val conversationId = 123L + val existingConversation = createTestConversation(id = conversationId).copy( + messages = listOf(BotMessage(1L, "Previous message", Date(), true)) + ) + val initialConversations = listOf(existingConversation, createTestConversation(id = 456L)) + val message = "Follow-up" + val updatedConversation = existingConversation.copy( + messages = listOf( + BotMessage(1L, "Previous message", Date(), true), + BotMessage(2L, message, Date(), true), + BotMessage(3L, "Bot response", Date(), false) + ) + ) + + whenever(aiBotSupportRepository.loadConversations()).thenReturn(initialConversations) + whenever(aiBotSupportRepository.loadConversation(conversationId)).thenReturn(existingConversation) + whenever(aiBotSupportRepository.sendMessageToConversation(conversationId, message)) + .thenReturn(updatedConversation) + + viewModel.init(testAccessToken, testUserId) + advanceUntilIdle() + + viewModel.onConversationSelected(existingConversation) + advanceUntilIdle() + + viewModel.sendMessage(message) + advanceUntilIdle() + + val updatedList = viewModel.conversations.value + assertThat(updatedList).hasSize(2) + val updatedInList = updatedList.find { it.id == conversationId } + assertThat(updatedInList?.lastMessage).isEqualTo("Bot response") + } + + // Helper functions + private fun createTestConversation( + id: Long, + lastMessage: String = "Test message" + ): BotConversation { + return BotConversation( + id = id, + createdAt = Date(), + mostRecentMessageDate = Date(), + lastMessage = lastMessage, + messages = emptyList() + ) + } + + private fun createTestConversations(count: Int = 2): List { + return (1..count).map { createTestConversation(id = it.toLong(), lastMessage = "Message $it") } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7278bc1f580d..07e2a0cb9e29 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,7 +102,7 @@ wiremock = '2.26.3' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = 'trunk-d0c9eebab77e8701810077ac1fba7d39ef8d121f' +wordpress-rs = 'trunk-1a64cb921601fd34bfe6030919960676d45a19c0' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.1'