From ab0d1f5778c8e95b2bf19cf529edf0e686f19c0e Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 19 Aug 2026 13:24:02 +0200 Subject: [PATCH 1/8] Improve unified support conversation flow with auto-refresh and adaptive CTA Add periodic and on-resume refresh for the conversations list and open chat, an adaptive reply CTA, an "Ongoing" status, and a smaller bot typing indicator. Co-Authored-By: Claude Opus 4.8 --- .../ui/ConversationsSupportViewModel.kt | 28 ++++++ .../unified/model/ConversationStatus.kt | 6 +- .../unified/ui/ConversationStatusBadge.kt | 9 +- .../ui/UnifiedConversationDetailScreen.kt | 90 +++++++++++++++---- .../unified/ui/UnifiedSupportActivity.kt | 28 ++++++ .../unified/ui/UnifiedSupportViewModel.kt | 35 ++++++++ WordPress/src/main/res/values/strings.xml | 4 +- 7 files changed, 170 insertions(+), 30 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index 981bebf04ff0..83cc53561982 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -132,6 +132,34 @@ abstract class ConversationsSupportViewModel( } } + /** + * Reloads the conversation list from the server without showing the pull-to-refresh spinner or a + * blocking loader. Used by the list screen's periodic auto-refresh and its refresh-on-resume so + * new or updated conversations appear while the screen stays open. Leaves the current list and + * error state untouched on failure (a background refresh should not surface an error). + */ + @Suppress("TooGenericExceptionCaught") + fun refreshConversationsSilently() { + viewModelScope.launch { + try { + if (!networkUtilsWrapper.isNetworkAvailable()) return@launch + val conversations = getConversations() + if (conversations != null) { + _conversations.value = conversations + // Don't stomp on an in-progress initial load; let its own completion set the state. + if (_conversationsState.value != ConversationsState.Loading) { + _conversationsState.value = ConversationsState.Loaded + } + } + } catch (throwable: Throwable) { + appLogWrapper.e( + AppLog.T.SUPPORT, "Error silently refreshing support conversations: " + + "${throwable.message} - ${throwable.stackTraceToString()}" + ) + } + } + } + fun clearError() { _errorMessage.value = null } diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/model/ConversationStatus.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/model/ConversationStatus.kt index 61a22ac98727..9d640be11c7c 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/model/ConversationStatus.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/model/ConversationStatus.kt @@ -1,8 +1,7 @@ package org.wordpress.android.support.unified.model enum class ConversationStatus { - WAITING_FOR_SUPPORT, - WAITING_FOR_USER, + ONGOING, CLOSED, SOLVED, UNKNOWN; @@ -10,9 +9,8 @@ enum class ConversationStatus { companion object { fun fromStatus(status: String): ConversationStatus { return when (status.lowercase()) { - "open", "new", "hold" -> WAITING_FOR_SUPPORT + "open", "new", "hold", "pending" -> ONGOING "closed" -> CLOSED - "pending" -> WAITING_FOR_USER "solved" -> SOLVED else -> UNKNOWN } diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/ConversationStatusBadge.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/ConversationStatusBadge.kt index 291e2054b6be..00c4ac2a261d 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/ConversationStatusBadge.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/ConversationStatusBadge.kt @@ -19,16 +19,11 @@ fun ConversationStatusBadge( ) { val conversationStatus = ConversationStatus.fromStatus(status) val (statusText, backgroundColor, textColor) = when (conversationStatus) { - ConversationStatus.WAITING_FOR_SUPPORT -> Triple( - stringResource(R.string.he_support_status_waiting_for_support), + ConversationStatus.ONGOING -> Triple( + stringResource(R.string.he_support_status_ongoing), MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer ) - ConversationStatus.WAITING_FOR_USER -> Triple( - stringResource(R.string.he_support_status_waiting_for_user), - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer - ) ConversationStatus.SOLVED -> Triple( stringResource(R.string.he_support_status_solved), MaterialTheme.colorScheme.primary, diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt index 1251610177f7..6138ca6e98c9 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.launch import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.ui.text.style.TextAlign +import androidx.annotation.StringRes import org.wordpress.android.R import org.wordpress.android.util.WPUrlUtils import org.wordpress.android.support.unified.util.formatRelativeTime @@ -114,6 +115,7 @@ fun UnifiedConversationDetailScreen( onReplyMessageChange: (String) -> Unit, onReplyIncludeAppLogsChange: (Boolean) -> Unit, onReplyBottomSheetVisibilityChange: (Boolean) -> Unit, + onAutoRefresh: () -> Unit, attachmentActionsListener: AttachmentActionsListener, ) { var previewAttachment by remember { mutableStateOf(null) } @@ -123,6 +125,19 @@ fun UnifiedConversationDetailScreen( val resources = LocalResources.current val isBot = conversation.isBot val isBotTyping = isBot && isSendingReply + val ctaLabelRes = replyCtaLabelRes(conversation) + + // Auto-refresh the open conversation every minute so replies from support appear without the + // user leaving the screen. Bots update locally, so we only poll HE conversations. Keying on the + // conversation id restarts the timer when a different conversation is opened. + if (!isBot) { + LaunchedEffect(conversation.id) { + while (true) { + kotlinx.coroutines.delay(CONVERSATION_AUTO_REFRESH_INTERVAL_MS) + onAutoRefresh() + } + } + } // Keep the conversation pinned to its last item whenever a message is added or the bot's typing // indicator appears/disappears. We scroll in reaction to layoutInfo.totalItemsCount rather than @@ -164,6 +179,8 @@ fun UnifiedConversationDetailScreen( ConversationBottomBar( isBot = isBot, canAcceptReply = conversation.canAcceptReply, + replyLabelRes = ctaLabelRes, + isLoading = isLoading, // The chat input is backed by the shared reply form state so the typed text is // retained on send failure (the VM clears it only on success) and survives // configuration changes via the ViewModel. @@ -259,6 +276,7 @@ fun UnifiedConversationDetailScreen( } UnifiedReplyBottomSheet( sheetState = sheetState, + titleRes = ctaLabelRes, isSending = isSendingReply, messageText = replyFormState.message, includeAppLogs = replyFormState.includeAppLogs, @@ -291,6 +309,8 @@ fun UnifiedConversationDetailScreen( private fun ConversationBottomBar( isBot: Boolean, canAcceptReply: Boolean, + @StringRes replyLabelRes: Int, + isLoading: Boolean, messageText: String, canSendMessage: Boolean, onMessageTextChange: (String) -> Unit, @@ -316,6 +336,8 @@ private fun ConversationBottomBar( canAcceptReply -> { Box(modifier = Modifier.navigationBarsPadding()) { ReplyButton( + labelRes = replyLabelRes, + isLoading = isLoading, enabled = replyEnabled, onClick = onReplyClick ) @@ -532,6 +554,7 @@ private fun ChatInputBar( @Composable private fun UnifiedReplyBottomSheet( sheetState: SheetState, + @StringRes titleRes: Int, isSending: Boolean, messageText: String, includeAppLogs: Boolean, @@ -572,7 +595,7 @@ private fun UnifiedReplyBottomSheet( } Text( - text = stringResource(R.string.he_support_reply_button), + text = stringResource(titleRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.semantics { heading() } @@ -611,10 +634,12 @@ private fun UnifiedReplyBottomSheet( @Composable private fun ReplyButton( + @StringRes labelRes: Int, + isLoading: Boolean, enabled: Boolean = true, onClick: () -> Unit ) { - val replyButtonLabel = stringResource(R.string.he_support_reply_button) + val replyButtonLabel = stringResource(labelRes) Box( modifier = Modifier .fillMaxWidth() @@ -622,23 +647,33 @@ private fun ReplyButton( ) { Button( onClick = onClick, - enabled = enabled, + enabled = enabled && !isLoading, modifier = Modifier .fillMaxWidth() .height(56.dp) - .semantics { contentDescription = replyButtonLabel }, + // Only expose the label once the conversation has loaded, so neither TalkBack nor + // the button flashes a transient/incorrect action while it's still being resolved. + .semantics { if (!isLoading) contentDescription = replyButtonLabel }, shape = RoundedCornerShape(28.dp) ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Reply, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = replyButtonLabel, - style = MaterialTheme.typography.titleMedium - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.Reply, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = replyButtonLabel, + style = MaterialTheme.typography.titleMedium + ) + } } } } @@ -692,13 +727,13 @@ private fun TypingIndicatorBubble() { bottomEnd = 16.dp ) ) - .padding(16.dp) + .padding(horizontal = 12.dp, vertical = 10.dp) .semantics { contentDescription = typingDescription } ) { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(3.dp), verticalAlignment = Alignment.CenterVertically ) { TypingDot(delay = 0) @@ -730,7 +765,7 @@ private fun TypingDot(delay: Int) { color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), shape = RoundedCornerShape(50) ) - .padding(4.dp) + .size(6.dp) ) } @@ -1058,7 +1093,28 @@ private fun WelcomeHeader(userName: String) { } } +/** + * Chooses the CTA label for an HE conversation: + * - "Reply" when the last message is from support (not the user and not the bot) — it's the user's + * turn to answer. + * - "Add more info" when the last message is the user's own, or the bot's (a freshly created + * ticket still shows the bot's opening message), i.e. the user is adding to an existing thread. + */ +@StringRes +private fun replyCtaLabelRes(conversation: UnifiedConversation): Int { + val lastMessage = conversation.messages.lastOrNull() + val isSupportAwaitingReply = lastMessage != null && + lastMessage.authorRole != UnifiedMessage.AUTHOR_ROLE_USER && + lastMessage.authorRole != UnifiedMessage.AUTHOR_ROLE_BOT + return if (isSupportAwaitingReply) { + R.string.he_support_reply_button + } else { + R.string.he_support_add_more_info_button + } +} + private const val PERCENT_MULTIPLIER = 100 private const val TYPING_DOT_DELAY_STEP = 150 private const val TYPING_DOT_PULSE_MS = 600L private const val TYPING_DOT_MIN_ALPHA = 0.3f +private const val CONVERSATION_AUTO_REFRESH_INTERVAL_MS = 60_000L diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt index 89e997f0e9f5..0fb41712e162 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt @@ -27,6 +27,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper @@ -52,6 +53,10 @@ class UnifiedSupportActivity : AppCompatActivity() { private lateinit var composeView: ComposeView private lateinit var navController: NavHostController + // Tracks whether onStart has already fired once, so the initial start (covered by init()'s load) + // doesn't trigger a redundant refresh. Reset when the Activity is recreated. + private var hasStarted = false + @Suppress("TooGenericExceptionCaught") private val photoPickerLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() @@ -92,6 +97,19 @@ class UnifiedSupportActivity : AppCompatActivity() { } } + override fun onStart() { + super.onStart() + // Refresh both the list and the open conversation each time the screen returns to the + // foreground so support replies are up to date. Skip the very first onStart (right after + // onCreate's initial load) to avoid a redundant request. On config-change recreation the + // ViewModel keeps its data, so refreshing again here is harmless. + if (hasStarted) { + viewModel.refreshConversationsSilently() + viewModel.refreshSelectedConversation() + } + hasStarted = true + } + private fun observeNavigationEvents() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -148,6 +166,14 @@ class UnifiedSupportActivity : AppCompatActivity() { composable(route = UnifiedScreen.List.name) { val conversationsState by viewModel.conversationsState.collectAsState() val conversations by viewModel.conversations.collectAsState() + // Auto-refresh the list every minute while it's on screen so new or updated + // conversations appear without the user pulling to refresh. + LaunchedEffect(Unit) { + while (true) { + delay(CONVERSATIONS_LIST_AUTO_REFRESH_INTERVAL_MS) + viewModel.refreshConversationsSilently() + } + } UnifiedConversationsListScreen( snackbarHostState = snackbarHostState, conversations = conversations, @@ -207,6 +233,7 @@ class UnifiedSupportActivity : AppCompatActivity() { onReplyBottomSheetVisibilityChange = { viewModel.updateReplyBottomSheetVisibility(it) }, + onAutoRefresh = { viewModel.refreshSelectedConversation() }, attachmentActionsListener = attachmentActionsListener, ) } @@ -247,6 +274,7 @@ class UnifiedSupportActivity : AppCompatActivity() { companion object { const val AUTHORIZATION_TAG = "Authorization" + private const val CONVERSATIONS_LIST_AUTO_REFRESH_INTERVAL_MS = 60_000L @JvmStatic fun createIntent(context: Context): Intent = Intent(context, UnifiedSupportActivity::class.java) diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt index 701e49b45258..ec2fd8a5c2c4 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt @@ -141,6 +141,41 @@ class UnifiedSupportViewModel @Inject constructor( override suspend fun getConversation(conversationId: Long): UnifiedConversation? = repository.loadConversation(conversationId) + /** + * Silently reloads the currently open conversation from the server, without showing the + * full-screen loading indicator. Used by the detail screen's periodic auto-refresh so new + * replies from support show up while the screen stays open. + * + * No-ops for bot conversations (they have no server-side changes to poll and a not-yet-created + * new conversation has no id to fetch) and while a reply is in flight or the conversation is + * still loading (so we never clobber the optimistic message or the initial load). + */ + @Suppress("TooGenericExceptionCaught") + fun refreshSelectedConversation() { + val conversation = _selectedConversation.value ?: return + if (conversation.isBot || conversation.id == NEW_CONVERSATION_ID) return + if (_isSendingReply.value || isLoadingConversation.value) return + viewModelScope.launch { + try { + if (!networkUtilsWrapper.isNetworkAvailable()) return@launch + val updated = repository.loadConversation(conversation.id) + // Re-check the state after the network call: only apply the update if the same + // conversation is still open and no reply started sending while we were fetching. + if (updated != null && + _selectedConversation.value?.id == updated.id && + !_isSendingReply.value + ) { + _selectedConversation.value = updated + } + } catch (throwable: Throwable) { + appLogWrapper.e( + AppLog.T.SUPPORT, "Error auto-refreshing conversation: " + + "${throwable.message} - ${throwable.stackTraceToString()}" + ) + } + } + } + @Suppress("TooGenericExceptionCaught", "LongMethod") fun sendReply(message: String, includeAppLogs: Boolean = false) { val conversation = _selectedConversation.value ?: return diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 08d3d9cd7766..a64eca7d59ff 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4707,6 +4707,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Last updated %1$s Reply + Add more info Send Message Downloading %1$s… @@ -4725,8 +4726,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Including logs can help our team investigate issues. Logs may contain recent app activity. Download attachment Select attachments - Waiting for Support - Waiting for User + Ongoing Solved Closed Unknown From b9cb484a64e8f58704dfe269c49d8296a35ae1be Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 19 Aug 2026 13:33:52 +0200 Subject: [PATCH 2/8] Confirm HE ticket reply with a snackbar After a Happiness Engineer ticket reply is sent successfully, show a transient snackbar ("Your reply has been sent. Check your email for updates.") so the user knows it worked. Bot chat sends are excluded. Co-Authored-By: Claude Opus 4.8 --- .../support/unified/ui/UnifiedSupportActivity.kt | 11 +++++++++++ .../support/unified/ui/UnifiedSupportViewModel.kt | 13 +++++++++++++ WordPress/src/main/res/values/strings.xml | 1 + 3 files changed, 25 insertions(+) diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt index 0fb41712e162..7efba661cc49 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt @@ -139,6 +139,17 @@ class UnifiedSupportActivity : AppCompatActivity() { val scope = rememberCoroutineScope() val errorMessage by viewModel.errorMessage.collectAsState() + // Confirm a successful HE ticket reply with a transient snackbar. The event is one-shot, so + // it never re-shows on recomposition or configuration change. + LaunchedEffect(Unit) { + viewModel.replySentEvents.collect { + snackbarHostState.showSnackbar( + message = getString(R.string.he_support_reply_sent_confirmation), + duration = SnackbarDuration.Long + ) + } + } + val errorType = errorMessage if (errorType != null) { val message = when (errorType) { diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt index ec2fd8a5c2c4..3e9f5d6b8048 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt @@ -4,8 +4,11 @@ import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -44,6 +47,12 @@ class UnifiedSupportViewModel @Inject constructor( private val _isSendingReply = MutableStateFlow(false) val isSendingReply: StateFlow = _isSendingReply.asStateFlow() + // One-shot event emitted after a Happiness Engineer ticket reply is successfully sent, so the UI + // can confirm it to the user. Not emitted for bot chat sends (they reply in-thread instantly and + // have no email follow-up). + private val _replySentEvents = MutableSharedFlow() + val replySentEvents: SharedFlow = _replySentEvents.asSharedFlow() + // Reply form state for HE-style conversations (survives configuration changes) private val _replyFormState = MutableStateFlow(ConversationReplyFormState()) val replyFormState: StateFlow = _replyFormState.asStateFlow() @@ -232,6 +241,10 @@ class UnifiedSupportViewModel @Inject constructor( } else { replaceInList(updated) } + // Confirm HE ticket replies only; bot chat sends need no confirmation. + if (!conversation.isBot) { + _replySentEvents.emit(Unit) + } } else { rollbackOptimisticMessage(conversation, optimisticMessage.id) rollbackInputMessage(message) diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index a64eca7d59ff..d8d1ae3a2a05 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -4709,6 +4709,7 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Reply Add more info Send + Your reply has been sent. Check your email for updates. Message Downloading %1$s… Loading image From 9559fbf09bb9fcb8f7da6fe77df23684aed6959b Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 19 Aug 2026 13:42:18 +0200 Subject: [PATCH 3/8] Fix detekt in refreshSelectedConversation Collapse the auto-refresh guards into a single local flag so the function stays within the ReturnCount limit without tripping ComplexCondition. Co-Authored-By: Claude Opus 4.8 --- .../android/support/unified/ui/UnifiedSupportViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt index 3e9f5d6b8048..e7bb54e39d8d 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt @@ -162,8 +162,12 @@ class UnifiedSupportViewModel @Inject constructor( @Suppress("TooGenericExceptionCaught") fun refreshSelectedConversation() { val conversation = _selectedConversation.value ?: return - if (conversation.isBot || conversation.id == NEW_CONVERSATION_ID) return - if (_isSendingReply.value || isLoadingConversation.value) return + // No-op for bots/new conversations and while a reply is in flight or the initial load runs. + val canRefresh = !conversation.isBot && + conversation.id != NEW_CONVERSATION_ID && + !_isSendingReply.value && + !isLoadingConversation.value + if (!canRefresh) return viewModelScope.launch { try { if (!networkUtilsWrapper.isNetworkAvailable()) return@launch From 8792fd589f95a6975c663206404d1b45d917e781 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 19 Aug 2026 13:58:02 +0200 Subject: [PATCH 4/8] Remove orphaned HE support status translations Delete the leftover translations for he_support_status_waiting_for_support and he_support_status_waiting_for_user across all locales. Their base strings were removed when the statuses were collapsed into "Ongoing", and lint flags translations without a base string (ExtraTranslation = error). Co-Authored-By: Claude Opus 4.8 --- WordPress/src/main/res/values-ar/strings.xml | 2 -- WordPress/src/main/res/values-bg/strings.xml | 2 -- WordPress/src/main/res/values-cs/strings.xml | 2 -- WordPress/src/main/res/values-de/strings.xml | 2 -- WordPress/src/main/res/values-en-rGB/strings.xml | 2 -- WordPress/src/main/res/values-es-rCO/strings.xml | 2 -- WordPress/src/main/res/values-es/strings.xml | 2 -- WordPress/src/main/res/values-fr-rCA/strings.xml | 2 -- WordPress/src/main/res/values-fr/strings.xml | 2 -- WordPress/src/main/res/values-he/strings.xml | 2 -- WordPress/src/main/res/values-id/strings.xml | 2 -- WordPress/src/main/res/values-it/strings.xml | 2 -- WordPress/src/main/res/values-ja/strings.xml | 2 -- WordPress/src/main/res/values-ko/strings.xml | 2 -- WordPress/src/main/res/values-nl/strings.xml | 2 -- WordPress/src/main/res/values-pl/strings.xml | 2 -- WordPress/src/main/res/values-pt-rBR/strings.xml | 2 -- WordPress/src/main/res/values-ro/strings.xml | 2 -- WordPress/src/main/res/values-ru/strings.xml | 2 -- WordPress/src/main/res/values-sq/strings.xml | 2 -- WordPress/src/main/res/values-sv/strings.xml | 2 -- WordPress/src/main/res/values-tr/strings.xml | 2 -- WordPress/src/main/res/values-zh-rCN/strings.xml | 2 -- WordPress/src/main/res/values-zh-rHK/strings.xml | 2 -- WordPress/src/main/res/values-zh-rTW/strings.xml | 2 -- 25 files changed, 50 deletions(-) diff --git a/WordPress/src/main/res/values-ar/strings.xml b/WordPress/src/main/res/values-ar/strings.xml index e1538f324a8e..46d88ce0c260 100644 --- a/WordPress/src/main/res/values-ar/strings.xml +++ b/WordPress/src/main/res/values-ar/strings.xml @@ -313,8 +313,6 @@ Language: ar يمكن أن يساعد تضمين السجلات فريقنا في التحقيق في المشكلات. قد تحتوي السجلات على نشاط التطبيق الأخير. تنزيل المرفق تحديد المرفقات - في انتظار الدعم - في انتظار المستخدم تم الحل مغلق غير معروف diff --git a/WordPress/src/main/res/values-bg/strings.xml b/WordPress/src/main/res/values-bg/strings.xml index 0c831cc70117..ed23aab4e48b 100644 --- a/WordPress/src/main/res/values-bg/strings.xml +++ b/WordPress/src/main/res/values-bg/strings.xml @@ -14,8 +14,6 @@ Language: bg Неизвестно Затворено Разрешено - Изчаква потребителя - Изчаква поддръжката Избор на прикачени файлове Изтегляне на прикачен файл Включването на дневници ще помогне на екипа ни да разреши проблема. Дневниците могат да съдържат скорошна дейност. diff --git a/WordPress/src/main/res/values-cs/strings.xml b/WordPress/src/main/res/values-cs/strings.xml index adbfdaeb59e2..730b1dbabc33 100644 --- a/WordPress/src/main/res/values-cs/strings.xml +++ b/WordPress/src/main/res/values-cs/strings.xml @@ -315,8 +315,6 @@ Language: cs_CZ Neznámý Uzavřeno Vyřešeno - Čeká na uživatele - Čeká na podporu Vybrat přílohy Stáhnout přílohu Přiložení záznamů může pomoci našemu týmu při vyšetřování problémů. Záznamy mohou obsahovat nedávnou aktivitu aplikace. diff --git a/WordPress/src/main/res/values-de/strings.xml b/WordPress/src/main/res/values-de/strings.xml index 57c72cd84ac7..b1a2661cd92b 100644 --- a/WordPress/src/main/res/values-de/strings.xml +++ b/WordPress/src/main/res/values-de/strings.xml @@ -315,8 +315,6 @@ Language: de Unbekannt Geschlossen Gelöst - Wartet auf Benutzer - Wartet auf Support Anhänge auswählen Anhang herunterladen Das Hinzufügen von Protokollen kann unserem Team helfen, Probleme zu untersuchen. Protokolle können aktuelle App-Aktivitäten enthalten. diff --git a/WordPress/src/main/res/values-en-rGB/strings.xml b/WordPress/src/main/res/values-en-rGB/strings.xml index eb4c47eefcd1..5a47dbc95042 100644 --- a/WordPress/src/main/res/values-en-rGB/strings.xml +++ b/WordPress/src/main/res/values-en-rGB/strings.xml @@ -315,8 +315,6 @@ Language: en_GB Unknown Closed Solved - Waiting for User - Waiting for Support Select attachments Download attachment Including logs can help our team investigate issues. Logs may contain recent app activity. diff --git a/WordPress/src/main/res/values-es-rCO/strings.xml b/WordPress/src/main/res/values-es-rCO/strings.xml index 28aecd732ecb..e51b415a26ef 100644 --- a/WordPress/src/main/res/values-es-rCO/strings.xml +++ b/WordPress/src/main/res/values-es-rCO/strings.xml @@ -315,8 +315,6 @@ Language: es_CO No se puede reproducir el vídeo Esta conversación está cerrada. Ya no puedes responder a ella. Solucionado - Esperando al usuario - Esperando al equipo de soporte Seleccionar adjuntos Descargar adjunto Incluir registros puede ayudar a nuestro equipo a investigar los problemas. Los registros pueden contener actividad reciente de la aplicación. diff --git a/WordPress/src/main/res/values-es/strings.xml b/WordPress/src/main/res/values-es/strings.xml index c882b96fa094..8b573d2b54e9 100644 --- a/WordPress/src/main/res/values-es/strings.xml +++ b/WordPress/src/main/res/values-es/strings.xml @@ -315,8 +315,6 @@ Language: es Desconocido Cerrado Solucionado - Esperando al usuario - Esperando al equipo de soporte Seleccionar adjuntos Descargar adjunto Incluir registros puede ayudar a nuestro equipo a investigar los problemas. Los registros pueden contener actividad reciente de la aplicación. diff --git a/WordPress/src/main/res/values-fr-rCA/strings.xml b/WordPress/src/main/res/values-fr-rCA/strings.xml index d651b17926f6..a61b82d4ed95 100644 --- a/WordPress/src/main/res/values-fr-rCA/strings.xml +++ b/WordPress/src/main/res/values-fr-rCA/strings.xml @@ -302,8 +302,6 @@ Language: fr Inconnu Fermé Résolu - En attente de l’utilisateur - En attente d’assistance Sélectionner les pièces jointes Télécharger la pièce jointe Inclure des journaux peut aider notre équipe à examiner les problèmes. Les journaux peuvent contenir une activité récente de l’application. diff --git a/WordPress/src/main/res/values-fr/strings.xml b/WordPress/src/main/res/values-fr/strings.xml index d651b17926f6..a61b82d4ed95 100644 --- a/WordPress/src/main/res/values-fr/strings.xml +++ b/WordPress/src/main/res/values-fr/strings.xml @@ -302,8 +302,6 @@ Language: fr Inconnu Fermé Résolu - En attente de l’utilisateur - En attente d’assistance Sélectionner les pièces jointes Télécharger la pièce jointe Inclure des journaux peut aider notre équipe à examiner les problèmes. Les journaux peuvent contenir une activité récente de l’application. diff --git a/WordPress/src/main/res/values-he/strings.xml b/WordPress/src/main/res/values-he/strings.xml index e3ffa47038ce..c60af21ac7dd 100644 --- a/WordPress/src/main/res/values-he/strings.xml +++ b/WordPress/src/main/res/values-he/strings.xml @@ -311,8 +311,6 @@ Language: he_IL לא ידוע סגור נפתר - בהמתנה למשתמש - בהמתנה לתמיכה לבחור קבצים מצורפים להוריד את הקובץ המצורף הוספה של יומני פעילות יכולה לעזור לצוות שלנו לחקור בעיות. יומני הפעילות עשויים להכיל פעולות שבוצעו באפליקציה לאחרונה. diff --git a/WordPress/src/main/res/values-id/strings.xml b/WordPress/src/main/res/values-id/strings.xml index eaa46bda95fa..1aee3402a10b 100644 --- a/WordPress/src/main/res/values-id/strings.xml +++ b/WordPress/src/main/res/values-id/strings.xml @@ -307,8 +307,6 @@ Language: id Tidak diketahui Ditutup Teratasi - Menunggu Pengguna - Menunggu Dukungan Pilih lampiran Unduh lampiran Menyertakan log dapat membantu tim kami menyelidiki kendala. Log mungkin berisi aktivitas aplikasi terbaru. diff --git a/WordPress/src/main/res/values-it/strings.xml b/WordPress/src/main/res/values-it/strings.xml index d318ece8b78e..54203588cb8d 100644 --- a/WordPress/src/main/res/values-it/strings.xml +++ b/WordPress/src/main/res/values-it/strings.xml @@ -315,8 +315,6 @@ Language: it Sconosciuto Chiuso Risolto - In attesa dell\'utente - In attesa di supporto Seleziona allegati Scarica allegato Includere i log può aiutare il nostro team a investigare i problemi. I log potrebbero contenere attività recenti delle app. diff --git a/WordPress/src/main/res/values-ja/strings.xml b/WordPress/src/main/res/values-ja/strings.xml index 64bb1367cf85..0b32b942aec5 100644 --- a/WordPress/src/main/res/values-ja/strings.xml +++ b/WordPress/src/main/res/values-ja/strings.xml @@ -312,8 +312,6 @@ Language: ja_JP 不明 クローズド 解決済み - ユーザーを待機中 - サポートを待機中 添付ファイルを選択 添付ファイルをダウンロード ログを添付すると問題の調査に役立ちます。 ログには最近のアプリのアクティビティが含まれている可能性があります。 diff --git a/WordPress/src/main/res/values-ko/strings.xml b/WordPress/src/main/res/values-ko/strings.xml index 6cc31522d328..0dbb27eaed88 100644 --- a/WordPress/src/main/res/values-ko/strings.xml +++ b/WordPress/src/main/res/values-ko/strings.xml @@ -310,8 +310,6 @@ Language: ko_KR 알 수 없음 닫힘 해결됨 - 사용자 대기 중 - 지원 대기 중 첨부 파일 선택 첨부 파일 다운로드 로그를 포함하면 팀에서 문제를 조사하는 데 도움이 될 수 있습니다. 로그에 최근 앱 활동이 포함될 수 있습니다. diff --git a/WordPress/src/main/res/values-nl/strings.xml b/WordPress/src/main/res/values-nl/strings.xml index dcbccf2ec848..52ee61af154b 100644 --- a/WordPress/src/main/res/values-nl/strings.xml +++ b/WordPress/src/main/res/values-nl/strings.xml @@ -315,8 +315,6 @@ Language: nl Onbekend Gesloten Opgelost - Wachten op gebruiker - Wachten op steun Bijlagen selecteren Download bijlage Het toevoegen van logs kan ons team helpen bij het onderzoeken van problemen. Logs kunnen recente app-activiteit bevatten. diff --git a/WordPress/src/main/res/values-pl/strings.xml b/WordPress/src/main/res/values-pl/strings.xml index 39c4253acd39..8e709096e512 100644 --- a/WordPress/src/main/res/values-pl/strings.xml +++ b/WordPress/src/main/res/values-pl/strings.xml @@ -315,8 +315,6 @@ Language: pl Nieznany Zamknięty Rozwiązany - Oczekiwanie na użytkownika - Oczekiwanie na pomoc techniczną Wybierz załączniki Pobierz załącznik Dołączenie dzienników może pomóc naszemu zespołowi w badaniu problemów. Dzienniki mogą zawierać ostatnią aktywność aplikacji. diff --git a/WordPress/src/main/res/values-pt-rBR/strings.xml b/WordPress/src/main/res/values-pt-rBR/strings.xml index e1f868f830c2..1b7ea6836a0b 100644 --- a/WordPress/src/main/res/values-pt-rBR/strings.xml +++ b/WordPress/src/main/res/values-pt-rBR/strings.xml @@ -14,8 +14,6 @@ Language: pt_BR Desconhecido Encerrado Resolvido - Aguardando usuário - Aguardando suporte Selecionar anexos Fazer download do anexo Incluir registros pode ajudar nossa equipe a investigar problemas. Os registros podem conter atividades recentes do aplicativo. diff --git a/WordPress/src/main/res/values-ro/strings.xml b/WordPress/src/main/res/values-ro/strings.xml index 3113a158abb3..607e8b16326b 100644 --- a/WordPress/src/main/res/values-ro/strings.xml +++ b/WordPress/src/main/res/values-ro/strings.xml @@ -315,8 +315,6 @@ Language: ro Necunoscută Închisă Rezolvată - Așteaptă suport - Așteaptă utilizatorul Selectează atașamente Descarcă atașamente Includerea jurnalelor poate ajuta echipa noastră să investigheze problemele. Jurnalele pot să conțină activități recente din aplicație. diff --git a/WordPress/src/main/res/values-ru/strings.xml b/WordPress/src/main/res/values-ru/strings.xml index a1e25ba491b3..32df490e5d7d 100644 --- a/WordPress/src/main/res/values-ru/strings.xml +++ b/WordPress/src/main/res/values-ru/strings.xml @@ -315,8 +315,6 @@ Language: ru Неизвестно Закрыто Задача решена - Ожидание пользователя - Ожидание поддержки Выбрать вложения Скачать вложение Приложенные журналы помогут нам понять, в чём причина проблем. Журналы могут содержать сведения о последних действиях в приложении. diff --git a/WordPress/src/main/res/values-sq/strings.xml b/WordPress/src/main/res/values-sq/strings.xml index 999f1ded80d7..322b65c3fe96 100644 --- a/WordPress/src/main/res/values-sq/strings.xml +++ b/WordPress/src/main/res/values-sq/strings.xml @@ -213,8 +213,6 @@ Language: sq_AL E panjohur E mbyllur E zgjidhur - Po pritet për Përdorues - Po pritet për Asistencën Përzgjidhni bashkëngjitje Shkarkoje bashkëngjitjen Përfshirja e regjistrave mund ta ndihmojë ekipin tonë të hetojë probleme. Regjistrat mund të përmbajnë veprimtari aplikacioni së fundi. diff --git a/WordPress/src/main/res/values-sv/strings.xml b/WordPress/src/main/res/values-sv/strings.xml index 07bb995db0f4..627ced50f796 100644 --- a/WordPress/src/main/res/values-sv/strings.xml +++ b/WordPress/src/main/res/values-sv/strings.xml @@ -315,8 +315,6 @@ Language: sv_SE Okänd Stängt Löst - Väntar på användare - Väntar på support Välj bilagor Ladda ner bilaga Att inkludera loggar kan hjälpa vårt team att undersöka problem. Loggar kan innehålla den senaste appaktiviteten. diff --git a/WordPress/src/main/res/values-tr/strings.xml b/WordPress/src/main/res/values-tr/strings.xml index 6b3b5bcec4f3..368d567be4d9 100644 --- a/WordPress/src/main/res/values-tr/strings.xml +++ b/WordPress/src/main/res/values-tr/strings.xml @@ -315,8 +315,6 @@ Language: tr Bilinmiyor Kapalı Çözüldü - Kullanıcı Bekleniyor - Destek Bekleniyor Ek seç Eki indir Günlükleri dahil etmek, ekibimizin sorunları araştırmasına yardımcı olabilir. Günlükler son uygulama etkinliğini içerebilir. diff --git a/WordPress/src/main/res/values-zh-rCN/strings.xml b/WordPress/src/main/res/values-zh-rCN/strings.xml index 857f3945155a..12c323bc5c88 100644 --- a/WordPress/src/main/res/values-zh-rCN/strings.xml +++ b/WordPress/src/main/res/values-zh-rCN/strings.xml @@ -312,8 +312,6 @@ Language: zh_CN 未知 已关闭 已解决 - 正在等待用户 - 正在等待支持人员 选择附件 下载附件 包含日志可以帮助我们的团队调查问题。 日志可能包含最近的应用程序活动。 diff --git a/WordPress/src/main/res/values-zh-rHK/strings.xml b/WordPress/src/main/res/values-zh-rHK/strings.xml index 4db765774f1c..92da0dfa9bd5 100644 --- a/WordPress/src/main/res/values-zh-rHK/strings.xml +++ b/WordPress/src/main/res/values-zh-rHK/strings.xml @@ -314,8 +314,6 @@ Language: zh_TW 不明 已關閉 已解決 - 正在等候使用者 - 正在等候支援 選取附件 下載附件 若包含記錄,就能協助我們的團隊調查問題。 記錄檔可能包含最近的應用程式活動。 diff --git a/WordPress/src/main/res/values-zh-rTW/strings.xml b/WordPress/src/main/res/values-zh-rTW/strings.xml index 4db765774f1c..92da0dfa9bd5 100644 --- a/WordPress/src/main/res/values-zh-rTW/strings.xml +++ b/WordPress/src/main/res/values-zh-rTW/strings.xml @@ -314,8 +314,6 @@ Language: zh_TW 不明 已關閉 已解決 - 正在等候使用者 - 正在等候支援 選取附件 下載附件 若包含記錄,就能協助我們的團隊調查問題。 記錄檔可能包含最近的應用程式活動。 From 9ace7313a2d13e830fcb71b1403c3fc1708b3aa4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Wed, 19 Aug 2026 16:05:47 +0200 Subject: [PATCH 5/8] Harden support auto-refresh and reply-sent event Scope both auto-refresh timers to the STARTED lifecycle so they stop polling while backgrounded, switch the reply-sent confirmation to a conflated Channel so the event can't be dropped or block the send's finally block, and guard the silent list refresh against pull-to-refresh races. Co-Authored-By: Claude Opus 4.8 --- .../ui/ConversationsSupportViewModel.kt | 29 +++++++++++++++---- .../ui/UnifiedConversationDetailScreen.kt | 20 +++++++++---- .../unified/ui/UnifiedSupportActivity.kt | 16 ++++++---- .../unified/ui/UnifiedSupportViewModel.kt | 23 ++++++++------- 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt index 83cc53561982..c7085bddab62 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/common/ui/ConversationsSupportViewModel.kt @@ -132,6 +132,11 @@ abstract class ConversationsSupportViewModel( } } + // Guards against overlapping silent refreshes (e.g. the minute timer and onStart firing close + // together) whose out-of-order responses could show an older list. Confined to the main thread + // (viewModelScope runs on Main), so no synchronization is needed. + private var isSilentRefreshInFlight = false + /** * Reloads the conversation list from the server without showing the pull-to-refresh spinner or a * blocking loader. Used by the list screen's periodic auto-refresh and its refresh-on-resume so @@ -140,22 +145,36 @@ abstract class ConversationsSupportViewModel( */ @Suppress("TooGenericExceptionCaught") fun refreshConversationsSilently() { + // Skip while a visible load or pull-to-refresh owns the state (so we neither cut its spinner + // short nor overwrite its newer result), and coalesce overlapping silent refreshes. + val state = _conversationsState.value + if (isSilentRefreshInFlight || + state == ConversationsState.Loading || + state == ConversationsState.Refreshing + ) { + return + } + isSilentRefreshInFlight = true viewModelScope.launch { try { if (!networkUtilsWrapper.isNetworkAvailable()) return@launch val conversations = getConversations() - if (conversations != null) { + // A visible load/refresh may have started while we were fetching; don't stomp it. + val currentState = _conversationsState.value + if (conversations != null && + currentState != ConversationsState.Loading && + currentState != ConversationsState.Refreshing + ) { _conversations.value = conversations - // Don't stomp on an in-progress initial load; let its own completion set the state. - if (_conversationsState.value != ConversationsState.Loading) { - _conversationsState.value = ConversationsState.Loaded - } + _conversationsState.value = ConversationsState.Loaded } } catch (throwable: Throwable) { appLogWrapper.e( AppLog.T.SUPPORT, "Error silently refreshing support conversations: " + "${throwable.message} - ${throwable.stackTraceToString()}" ) + } finally { + isSilentRefreshInFlight = false } } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt index 6138ca6e98c9..31e879e59d3e 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt @@ -83,6 +83,9 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.ui.text.style.TextAlign import androidx.annotation.StringRes +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import org.wordpress.android.R import org.wordpress.android.util.WPUrlUtils import org.wordpress.android.support.unified.util.formatRelativeTime @@ -126,15 +129,20 @@ fun UnifiedConversationDetailScreen( val isBot = conversation.isBot val isBotTyping = isBot && isSendingReply val ctaLabelRes = replyCtaLabelRes(conversation) + val lifecycleOwner = LocalLifecycleOwner.current // Auto-refresh the open conversation every minute so replies from support appear without the - // user leaving the screen. Bots update locally, so we only poll HE conversations. Keying on the - // conversation id restarts the timer when a different conversation is opened. + // user leaving the screen. Bots update locally, so we only poll HE conversations. The poll is + // scoped to STARTED via repeatOnLifecycle so it pauses while the app is backgrounded (onStart + // already refreshes on return). Keying on the conversation id restarts the timer when a + // different conversation is opened. if (!isBot) { - LaunchedEffect(conversation.id) { - while (true) { - kotlinx.coroutines.delay(CONVERSATION_AUTO_REFRESH_INTERVAL_MS) - onAutoRefresh() + LaunchedEffect(conversation.id, lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + kotlinx.coroutines.delay(CONVERSATION_AUTO_REFRESH_INTERVAL_MS) + onAutoRefresh() + } } } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt index 7efba661cc49..3c7243330108 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportActivity.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.net.toUri import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController @@ -177,12 +178,17 @@ class UnifiedSupportActivity : AppCompatActivity() { composable(route = UnifiedScreen.List.name) { val conversationsState by viewModel.conversationsState.collectAsState() val conversations by viewModel.conversations.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current // Auto-refresh the list every minute while it's on screen so new or updated - // conversations appear without the user pulling to refresh. - LaunchedEffect(Unit) { - while (true) { - delay(CONVERSATIONS_LIST_AUTO_REFRESH_INTERVAL_MS) - viewModel.refreshConversationsSilently() + // conversations appear without the user pulling to refresh. Scoped to STARTED via + // repeatOnLifecycle so the timer pauses while the app is backgrounded (onStart + // already refreshes on return). + LaunchedEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + delay(CONVERSATIONS_LIST_AUTO_REFRESH_INTERVAL_MS) + viewModel.refreshConversationsSilently() + } } } UnifiedConversationsListScreen( diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt index e7bb54e39d8d..25cdfe3e685f 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt @@ -4,12 +4,12 @@ import android.net.Uri import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.wordpress.android.fluxc.store.AccountStore @@ -47,11 +47,13 @@ class UnifiedSupportViewModel @Inject constructor( private val _isSendingReply = MutableStateFlow(false) val isSendingReply: StateFlow = _isSendingReply.asStateFlow() - // One-shot event emitted after a Happiness Engineer ticket reply is successfully sent, so the UI - // can confirm it to the user. Not emitted for bot chat sends (they reply in-thread instantly and - // have no email follow-up). - private val _replySentEvents = MutableSharedFlow() - val replySentEvents: SharedFlow = _replySentEvents.asSharedFlow() + // Consumable one-shot event emitted after a Happiness Engineer ticket reply is successfully sent, + // so the UI can confirm it to the user (not emitted for bot chat sends — they reply in-thread and + // have no email follow-up). A CONFLATED channel buffers the event when no collector is attached + // (e.g. the composition-restart gap during rotation) so it isn't dropped, and trySend never + // suspends, so emitting it can't stall the reply's finally block. + private val _replySentEvents = Channel(Channel.CONFLATED) + val replySentEvents: Flow = _replySentEvents.receiveAsFlow() // Reply form state for HE-style conversations (survives configuration changes) private val _replyFormState = MutableStateFlow(ConversationReplyFormState()) @@ -245,9 +247,10 @@ class UnifiedSupportViewModel @Inject constructor( } else { replaceInList(updated) } - // Confirm HE ticket replies only; bot chat sends need no confirmation. + // Confirm HE ticket replies only; bot chat sends need no confirmation. trySend is + // non-suspending, so it never delays the finally block below. if (!conversation.isBot) { - _replySentEvents.emit(Unit) + _replySentEvents.trySend(Unit) } } else { rollbackOptimisticMessage(conversation, optimisticMessage.id) From 834b88175b384792b8f465b222bba6447022a204 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 21 Aug 2026 13:21:41 +0200 Subject: [PATCH 6/8] Bump wordpress-rs to 1586 build to test bot context.sources fix Upgrade the wordpress-rs binding to the PR build and adapt to its API changes: handle the new MediaFileUnreadable / ConnectionError result variants, and switch the comments list-query param to the dedicated WpApiParamCommentsStatus type (with native All/Approve/Hold/Spam/Trash variants replacing the previous Custom(...) workaround). Co-Authored-By: Claude Opus 4.8 --- .../comments/unified/CommentsRsDataSource.kt | 3 +- .../ui/commentsrs/CommentsRsListTab.kt | 31 +++++++++---------- gradle/libs.versions.toml | 2 +- .../rest/wpapi/media/MediaRSApiRestClient.kt | 10 +++++- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt index 618db04b80a6..27d0d07ca9d4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/comments/unified/CommentsRsDataSource.kt @@ -21,6 +21,7 @@ import uniffi.wp_api.UniffiWpApiClient import uniffi.wp_api.UserAvatarSize import uniffi.wp_api.WpErrorCode import uniffi.wp_api.WpApiParamCommentsOrderBy +import uniffi.wp_api.WpApiParamCommentsStatus import uniffi.wp_api.WpApiParamOrder import java.util.Date import java.util.concurrent.ConcurrentHashMap @@ -143,7 +144,7 @@ class CommentsRsDataSource @Inject constructor( } } - fun firstPageParams(status: RsCommentStatus?, search: String? = null): CommentListParams = + fun firstPageParams(status: WpApiParamCommentsStatus?, search: String? = null): CommentListParams = CommentListParams( perPage = COMMENTS_PAGE_SIZE, search = search, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt index 69d4c0a5ef76..811d70715003 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/commentsrs/CommentsRsListTab.kt @@ -2,62 +2,61 @@ package org.wordpress.android.ui.commentsrs import androidx.annotation.StringRes import org.wordpress.android.R -import uniffi.wp_api.CommentStatus as RsCommentStatus +import uniffi.wp_api.WpApiParamCommentsStatus /** * Filter tabs for the rs comments list, matching the legacy unified list. * - * [queryStatus] is the `status` query param for `/wp/v2/comments`. Three tabs use - * [RsCommentStatus.Custom] because `WP_Comment_Query` only recognises the literal values - * `approve` and `all` — wordpress-rs serialises [RsCommentStatus.Approved] as `approved`, - * which WordPress core treats as an unknown status and returns nothing for (wordpress-rs - * `comments.rs` serialisation test asserts `status=approved`). `all` means approved+hold, - * matching the legacy ALL filter (APPROVED+UNAPPROVED). + * [queryStatus] is the `status` query param for `/wp/v2/comments`. [WpApiParamCommentsStatus.All] + * means approved+hold, matching the legacy ALL filter (APPROVED+UNAPPROVED). The dedicated + * [WpApiParamCommentsStatus] variants serialise to the literal values `WP_Comment_Query` expects + * (`all`, `approve`, `hold`, `spam`, `trash`). * - * [UNREPLIED] has no server status: it queries `all` (like [ALL]) and is threaded client-side by - * [filterUnreplied] to keep only top-level comments the user hasn't replied to, mirroring legacy. + * [UNREPLIED] has no server status: it queries [WpApiParamCommentsStatus.All] (like [ALL]) and is + * threaded client-side by [filterUnreplied] to keep only top-level comments the user hasn't + * replied to, mirroring legacy. */ enum class CommentsRsListTab( @StringRes val labelResId: Int, @StringRes val emptyMessageResId: Int, /** The `selected_filter` property value for COMMENT_FILTER_CHANGED, matching the legacy list. */ @StringRes val trackingLabelResId: Int, - val queryStatus: RsCommentStatus + val queryStatus: WpApiParamCommentsStatus ) { ALL( R.string.comment_status_all, R.string.comments_empty_list, R.string.comment_tracker_label_all, - RsCommentStatus.Custom("all") + WpApiParamCommentsStatus.All ), PENDING( R.string.comment_status_unapproved, R.string.comments_empty_list_filtered_pending, R.string.comment_tracker_label_pending, - RsCommentStatus.Hold + WpApiParamCommentsStatus.Hold ), UNREPLIED( R.string.comment_status_unreplied, R.string.comments_empty_list_filtered_unreplied, R.string.comment_tracker_label_unreplied, - RsCommentStatus.Custom("all") + WpApiParamCommentsStatus.All ), APPROVED( R.string.comment_status_approved, R.string.comments_empty_list_filtered_approved, R.string.comment_tracker_label_approved, - RsCommentStatus.Custom("approve") + WpApiParamCommentsStatus.Approve ), SPAM( R.string.comment_status_spam, R.string.comments_empty_list_filtered_spam, R.string.comment_tracker_label_spam, - RsCommentStatus.Spam + WpApiParamCommentsStatus.Spam ), TRASHED( R.string.comment_status_trash, R.string.comments_empty_list_filtered_trashed, R.string.comment_tracker_label_trashed, - RsCommentStatus.Trash + WpApiParamCommentsStatus.Trash ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76c29d3c9552..73a5f84cd0fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -101,7 +101,7 @@ wellsql = '2.0.0' wordpress-aztec = 'v2.1.4' wordpress-lint = '2.2.0' wordpress-persistent-edittext = '1.0.2' -wordpress-rs = '0.6.0' +wordpress-rs = '1586-61c642e514bd58492e308c908d80ef145b74e9bf' wordpress-utils = '3.14.0' automattic-ucrop = '2.2.11' zendesk = '5.5.3' diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt index 661f17dfe7e7..f9d198425162 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaRSApiRestClient.kt @@ -152,6 +152,13 @@ class MediaRSApiRestClient @Inject constructor( } } + is WpRequestResult.MediaFileUnreadable<*> -> { + appLogWrapper.e(AppLog.T.MEDIA, "Media file unreadable: $mediaResponse") + MediaError(MediaErrorType.FS_READ_PERMISSION_DENIED).apply { + message = "Media file could not be read" + } + } + is WpRequestResult.ResponseParsingError<*> -> { appLogWrapper.e(AppLog.T.MEDIA, "Response parsing error: $mediaResponse") MediaError(MediaErrorType.PARSE_ERROR).apply { @@ -257,7 +264,8 @@ class MediaRSApiRestClient @Inject constructor( when (reason) { is RequestExecutionErrorReason.HttpTimeoutError -> MediaErrorType.TIMEOUT is RequestExecutionErrorReason.DeviceIsOfflineError, - is RequestExecutionErrorReason.InvalidSslError -> MediaErrorType.CONNECTION_ERROR + is RequestExecutionErrorReason.InvalidSslError, + is RequestExecutionErrorReason.ConnectionError -> MediaErrorType.CONNECTION_ERROR is RequestExecutionErrorReason.NonExistentSiteError -> MediaErrorType.NOT_FOUND // Keep this aligned with MediaErrorType.fromHttpStatusCode: a 403 (forbidden) maps to // NOT_AUTHENTICATED, while a 401 (auth required/rejected/misconfigured) maps to From 689c4c86ba033f783fcca99fba5e354fc10ec6c4 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 21 Aug 2026 16:15:55 +0200 Subject: [PATCH 7/8] Guard auto-refresh against overwriting a just-sent reply A conversation poll started before a reply send could finish afterwards and restore a pre-reply snapshot, making the reply vanish right after the "reply sent" confirmation. Add a generation counter bumped when a send begins so a stale poll drops its result, plus an in-flight flag so overlapping polls can't apply out of order. Co-Authored-By: Claude Opus 4.8 --- .../unified/ui/UnifiedSupportViewModel.kt | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt index 25cdfe3e685f..9abe2be88630 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedSupportViewModel.kt @@ -55,6 +55,14 @@ class UnifiedSupportViewModel @Inject constructor( private val _replySentEvents = Channel(Channel.CONFLATED) val replySentEvents: Flow = _replySentEvents.receiveAsFlow() + // Auto-refresh race guards (all touched only on the main thread): + // [conversationMutationGeneration] is bumped synchronously the moment a reply send begins, so an + // auto-refresh poll that started before the send discards its now-stale result instead of + // restoring a pre-reply snapshot over the just-sent reply. [isRefreshingSelectedConversation] + // serialises polls so overlapping responses can't land out of order. + private var conversationMutationGeneration = 0L + private var isRefreshingSelectedConversation = false + // Reply form state for HE-style conversations (survives configuration changes) private val _replyFormState = MutableStateFlow(ConversationReplyFormState()) val replyFormState: StateFlow = _replyFormState.asStateFlow() @@ -158,28 +166,34 @@ class UnifiedSupportViewModel @Inject constructor( * replies from support show up while the screen stays open. * * No-ops for bot conversations (they have no server-side changes to poll and a not-yet-created - * new conversation has no id to fetch) and while a reply is in flight or the conversation is - * still loading (so we never clobber the optimistic message or the initial load). + * new conversation has no id to fetch), while a reply is in flight or the conversation is still + * loading (so we never clobber the optimistic message or the initial load), and while another + * poll is already running (so overlapping responses can't land out of order). */ @Suppress("TooGenericExceptionCaught") fun refreshSelectedConversation() { val conversation = _selectedConversation.value ?: return - // No-op for bots/new conversations and while a reply is in flight or the initial load runs. val canRefresh = !conversation.isBot && conversation.id != NEW_CONVERSATION_ID && !_isSendingReply.value && - !isLoadingConversation.value + !isLoadingConversation.value && + !isRefreshingSelectedConversation if (!canRefresh) return + // Snapshot the generation before fetching; if a send bumps it while we're on the network, + // this poll's result is stale and must be dropped (see conversationMutationGeneration). + val generationAtStart = conversationMutationGeneration + isRefreshingSelectedConversation = true viewModelScope.launch { try { if (!networkUtilsWrapper.isNetworkAvailable()) return@launch val updated = repository.loadConversation(conversation.id) - // Re-check the state after the network call: only apply the update if the same - // conversation is still open and no reply started sending while we were fetching. - if (updated != null && - _selectedConversation.value?.id == updated.id && - !_isSendingReply.value - ) { + // Apply only if nothing changed the open conversation while we were fetching: same + // conversation still open, no reply sending, and no send began since we started. + val stillCurrent = updated != null && + _selectedConversation.value?.id == updated.id && + !_isSendingReply.value && + conversationMutationGeneration == generationAtStart + if (stillCurrent) { _selectedConversation.value = updated } } catch (throwable: Throwable) { @@ -187,6 +201,8 @@ class UnifiedSupportViewModel @Inject constructor( AppLog.T.SUPPORT, "Error auto-refreshing conversation: " + "${throwable.message} - ${throwable.stackTraceToString()}" ) + } finally { + isRefreshingSelectedConversation = false } } } @@ -199,6 +215,9 @@ class UnifiedSupportViewModel @Inject constructor( // gets to set it. if (_isSendingReply.value) return _isSendingReply.value = true + // Invalidate any auto-refresh poll already in flight: its response predates this send, so it + // must not overwrite the reply we're about to add. + conversationMutationGeneration++ viewModelScope.launch { if (!networkUtilsWrapper.isNetworkAvailable()) { From 371ea1452e31411f5e44980c80a39d272c05a535 Mon Sep 17 00:00:00 2001 From: adalpari Date: Fri, 21 Aug 2026 16:18:35 +0200 Subject: [PATCH 8/8] Make reply button loading spinner visible when disabled The in-button spinner used onPrimary while the button is forced into its disabled state (greyed container), leaving it invisible in both themes. Tint it with the Material 3 disabled content color so it reads as the greyed-out control the user expects. Co-Authored-By: Claude Opus 4.8 --- .../support/unified/ui/UnifiedConversationDetailScreen.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt index 31e879e59d3e..11eae669698e 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/unified/ui/UnifiedConversationDetailScreen.kt @@ -665,10 +665,14 @@ private fun ReplyButton( shape = RoundedCornerShape(28.dp) ) { if (isLoading) { + // The button is disabled while loading, so its container is greyed + // (onSurface @ 12%). Tint the spinner with the Material 3 disabled content + // color (onSurface @ 38%) so it stays visible in both light and dark themes, + // matching the greyed-out label the button shows when disabled. CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } else { Icon(