Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,53 @@ abstract class ConversationsSupportViewModel<ConversationType: Conversation>(
}
}

// 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
* 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() {
// 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()
// 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
_conversationsState.value = ConversationsState.Loaded
}
} catch (throwable: Throwable) {
appLogWrapper.e(
AppLog.T.SUPPORT, "Error silently refreshing support conversations: " +
"${throwable.message} - ${throwable.stackTraceToString()}"
)
} finally {
isSilentRefreshInFlight = false
}
}
}

fun clearError() {
_errorMessage.value = null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
package org.wordpress.android.support.unified.model

enum class ConversationStatus {
WAITING_FOR_SUPPORT,
WAITING_FOR_USER,
ONGOING,
CLOSED,
SOLVED,
UNKNOWN;

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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ 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 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
Expand Down Expand Up @@ -114,6 +118,7 @@ fun UnifiedConversationDetailScreen(
onReplyMessageChange: (String) -> Unit,
onReplyIncludeAppLogsChange: (Boolean) -> Unit,
onReplyBottomSheetVisibilityChange: (Boolean) -> Unit,
onAutoRefresh: () -> Unit,
attachmentActionsListener: AttachmentActionsListener,
) {
var previewAttachment by remember { mutableStateOf<UnifiedAttachment?>(null) }
Expand All @@ -123,6 +128,24 @@ fun UnifiedConversationDetailScreen(
val resources = LocalResources.current
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. 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, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
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
Expand Down Expand Up @@ -164,6 +187,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.
Expand Down Expand Up @@ -259,6 +284,7 @@ fun UnifiedConversationDetailScreen(
}
UnifiedReplyBottomSheet(
sheetState = sheetState,
titleRes = ctaLabelRes,
isSending = isSendingReply,
messageText = replyFormState.message,
includeAppLogs = replyFormState.includeAppLogs,
Expand Down Expand Up @@ -291,6 +317,8 @@ fun UnifiedConversationDetailScreen(
private fun ConversationBottomBar(
isBot: Boolean,
canAcceptReply: Boolean,
@StringRes replyLabelRes: Int,
isLoading: Boolean,
messageText: String,
canSendMessage: Boolean,
onMessageTextChange: (String) -> Unit,
Expand All @@ -316,6 +344,8 @@ private fun ConversationBottomBar(
canAcceptReply -> {
Box(modifier = Modifier.navigationBarsPadding()) {
ReplyButton(
labelRes = replyLabelRes,
isLoading = isLoading,
enabled = replyEnabled,
onClick = onReplyClick
)
Expand Down Expand Up @@ -532,6 +562,7 @@ private fun ChatInputBar(
@Composable
private fun UnifiedReplyBottomSheet(
sheetState: SheetState,
@StringRes titleRes: Int,
isSending: Boolean,
messageText: String,
includeAppLogs: Boolean,
Expand Down Expand Up @@ -572,7 +603,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() }
Expand Down Expand Up @@ -611,34 +642,50 @@ 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()
.padding(16.dp)
) {
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) {
// 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.onSurface.copy(alpha = 0.38f)
)
} 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
)
}
}
}
}
Expand Down Expand Up @@ -692,13 +739,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)
Expand Down Expand Up @@ -730,7 +777,7 @@ private fun TypingDot(delay: Int) {
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha),
shape = RoundedCornerShape(50)
)
.padding(4.dp)
.size(6.dp)
)
}

Expand Down Expand Up @@ -1058,7 +1105,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
Loading
Loading